feat: add container manager API, islop router, and container runtime files with vim/bot/d stealth clients
Some checks failed
DevPlace CI / test (push) Failing after 38m17s
Some checks failed
DevPlace CI / test (push) Failing after 38m17s
This commit is contained in:
parent
9a8046ab2a
commit
499f91e16a
580
devplacepy/docs_api/groups/containers.py.bak
Normal file
580
devplacepy/docs_api/groups/containers.py.bak
Normal file
@ -0,0 +1,580 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "containers",
|
||||
"title": "Container Manager",
|
||||
"admin": True,
|
||||
"intro": """
|
||||
# Container Manager
|
||||
|
||||
Run supervised container instances for a project. There is no in-app image building: every instance
|
||||
runs one shared prebuilt image (`ppy:latest`) with the project's workspace mounted at `/app`. Every
|
||||
endpoint is **administrator only** (docker socket access is root-equivalent). Mutations flip desired
|
||||
state; a single reconciler converges containers to it.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="containers-page",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers",
|
||||
title="Container manager page",
|
||||
summary="The admin per-project container manager UI (instance creation and lifecycle). Returns 404 for an administrator who is not the owner of an administrator-hidden project.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-index",
|
||||
method="GET",
|
||||
path="/admin/containers",
|
||||
title="Admin containers list",
|
||||
summary="The admin Containers section: every instance across all projects, each linking to its detail page. Instances attached to another administrator's hidden project are excluded, and per-instance actions return 404 for a non-owner administrator.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-data",
|
||||
method="GET",
|
||||
path="/admin/containers/data",
|
||||
title="Admin containers list data",
|
||||
summary="JSON of every instance across all projects (decorated with project title/slug) for polling.",
|
||||
auth="admin",
|
||||
sample_response={
|
||||
"instances": [
|
||||
{
|
||||
"uid": "INSTANCE_UID",
|
||||
"name": "staging",
|
||||
"status": "running",
|
||||
"project_slug": "PROJECT_SLUG",
|
||||
"project_title": "My Project",
|
||||
"ingress_slug": "my-service",
|
||||
"restart_policy": "always",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-instance",
|
||||
method="GET",
|
||||
path="/admin/containers/{uid}",
|
||||
title="Instance detail page",
|
||||
summary="The dedicated detail page for one instance (lifecycle, logs, metrics, terminal, schedules, ingress, sync).",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-edit-page",
|
||||
method="GET",
|
||||
path="/admin/containers/{uid}/edit",
|
||||
title="Edit instance page",
|
||||
summary="The edit page for one instance (run-as user, boot language/script/command, restart policy, start-on-boot, limits).",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-create-instance",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances",
|
||||
title="Create an instance",
|
||||
summary="Create and (by default) start an instance; it runs the shared ppy image with the project workspace mounted at /app.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field("name", "form", "string", True, "staging", "Instance name."),
|
||||
field(
|
||||
"boot_command",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"python app.py",
|
||||
"Optional boot command.",
|
||||
),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
|
||||
field(
|
||||
"env",
|
||||
"form",
|
||||
"textarea",
|
||||
False,
|
||||
"KEY=VALUE",
|
||||
"Env vars, one KEY=VALUE per line.",
|
||||
),
|
||||
field(
|
||||
"ports",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"80",
|
||||
"Port maps. Bare container port auto-assigns a unique host port above 20000; host:container pins one.",
|
||||
),
|
||||
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
||||
field(
|
||||
"mem_limit", "form", "string", False, "512m", "Memory limit."
|
||||
),
|
||||
field(
|
||||
"restart_policy",
|
||||
"form",
|
||||
"enum",
|
||||
False,
|
||||
"never",
|
||||
"Restart policy.",
|
||||
["never", "always", "on-failure", "unless-stopped"],
|
||||
),
|
||||
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
|
||||
field(
|
||||
"ingress_slug",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"my-service",
|
||||
"Publish at /p/<slug> (optional).",
|
||||
),
|
||||
field(
|
||||
"ingress_port",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"8899",
|
||||
"Container port to publish (must be a mapped port).",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-ingress",
|
||||
method="GET",
|
||||
path="/p/{slug}",
|
||||
title="Container ingress proxy",
|
||||
summary="Public reverse proxy (HTTP and WebSocket) to a running instance published via ingress_slug. The /p/<slug> prefix is stripped before forwarding.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"my-service",
|
||||
"The instance's ingress_slug.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-action",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/{action}",
|
||||
title="Instance lifecycle",
|
||||
summary="start, stop, restart, pause, or resume an instance (flips desired state).",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"action",
|
||||
"path",
|
||||
"enum",
|
||||
True,
|
||||
"start",
|
||||
"Lifecycle action.",
|
||||
["start", "stop", "restart", "pause", "resume"],
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-logs",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/logs",
|
||||
title="Instance logs",
|
||||
summary="Recent docker logs of a running instance.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field("tail", "query", "integer", False, "200", "Number of lines."),
|
||||
],
|
||||
sample_response={"logs": "..."},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-sync",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/sync",
|
||||
title="Sync workspace",
|
||||
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
sample_response={"exported": 3, "imported": 1},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-delete",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/delete",
|
||||
title="Delete instance",
|
||||
summary="Remove a container instance and mark its container for removal.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-exec",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/exec",
|
||||
title="Exec a command",
|
||||
summary="Run a one-shot command inside a running instance and return its output.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"command",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"ls -la /app",
|
||||
"Shell command to run (via /bin/sh -c).",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-data",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}",
|
||||
title="Instance detail data",
|
||||
summary="Return the full instance row plus runtime info as JSON.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
sample_response={"uid": "INSTANCE_UID", "name": "staging", "status": "running"},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-metrics",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/metrics",
|
||||
title="Instance metrics",
|
||||
summary="Return recent metrics ring-buffer and aggregated stats for a running instance.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
sample_response={"metrics": [], "stats": {}},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-schedules",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/schedules",
|
||||
title="Create a schedule",
|
||||
summary="Attach a cron, one-time, interval, or delay schedule to an instance.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"action",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"start",
|
||||
"Lifecycle action to run on schedule (start, stop, restart).",
|
||||
),
|
||||
field(
|
||||
"kind",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"cron",
|
||||
"Schedule kind: cron, once, interval, or delay.",
|
||||
),
|
||||
field(
|
||||
"cron",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"0 * * * *",
|
||||
"Cron expression (when kind is cron).",
|
||||
),
|
||||
field(
|
||||
"run_at",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"2026-01-01T00:00:00",
|
||||
"ISO timestamp for a one-time run (when kind is once).",
|
||||
),
|
||||
field(
|
||||
"delay_seconds",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"60",
|
||||
"Seconds to wait before a single run (when kind is delay).",
|
||||
),
|
||||
field(
|
||||
"every_seconds",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"300",
|
||||
"Interval in seconds between runs (when kind is interval).",
|
||||
),
|
||||
field(
|
||||
"max_runs",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"10",
|
||||
"Optional cap on the number of runs.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-schedule-delete",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/schedules/{sid}/delete",
|
||||
title="Delete a schedule",
|
||||
summary="Remove a schedule from an instance.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"sid", "path", "string", True, "SCHEDULE_UID", "Schedule uid."
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-create",
|
||||
method="POST",
|
||||
path="/admin/containers/create",
|
||||
title="Admin create instance",
|
||||
summary="Create an instance from the admin Containers page: project search-select, run-as user, boot language/script, restart policy, start-on-boot, plus the usual options.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("project_slug", "form", "string", True, "PROJECT_SLUG", "Project that becomes the /app root."),
|
||||
field("name", "form", "string", True, "staging", "Instance name."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
|
||||
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command when no boot_script is set."),
|
||||
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
|
||||
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
|
||||
field("env", "form", "textarea", False, "KEY=VALUE", "Env vars, one KEY=VALUE per line."),
|
||||
field("ports", "form", "string", False, "80", "Port maps; bare container port auto-assigns a host port above 20000."),
|
||||
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
||||
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
|
||||
field("ingress_slug", "form", "string", False, "my-service", "Publish at /p/<slug> (optional)."),
|
||||
field("ingress_port", "form", "integer", False, "8899", "Container port to publish."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-edit",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/edit",
|
||||
title="Admin edit instance",
|
||||
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "Run-as user uid (identity + API key only)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code."),
|
||||
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command."),
|
||||
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
|
||||
field("start_on_boot", "form", "boolean", False, "false", "Force running on container-service boot."),
|
||||
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
||||
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-action",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/{action}",
|
||||
title="Admin instance lifecycle",
|
||||
summary="start, stop, restart, pause, or resume an instance from the admin Containers page (flips desired state).",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
field("action", "path", "enum", True, "start", "Lifecycle action.", ["start", "stop", "restart", "pause", "resume"]),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-sync",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/sync",
|
||||
title="Admin bidirectional sync",
|
||||
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
],
|
||||
sample_response={"exported": 3, "imported": 1},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-delete",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/delete",
|
||||
title="Admin delete instance",
|
||||
summary="Soft-delete an instance and mark its container for removal.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-project-search",
|
||||
method="GET",
|
||||
path="/admin/containers/projects/search",
|
||||
title="Admin project search",
|
||||
summary="Search projects by title for the admin create form (returns uid, slug, title).",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("q", "query", "string", False, "api", "Title fragment."),
|
||||
],
|
||||
sample_response={"results": [{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "title": "My Project"}]},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-user-search",
|
||||
method="GET",
|
||||
path="/admin/containers/users/search",
|
||||
title="Admin run-as user search",
|
||||
summary="Search users by username for the run-as-user select (returns uid, username).",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("q", "query", "string", False, "alice", "Username fragment."),
|
||||
],
|
||||
sample_response={"results": [{"uid": "USER_UID", "username": "alice"}]},
|
||||
),
|
||||
],
|
||||
}
|
||||
506
devplacepy/routers/tools/isslop.py
Normal file
506
devplacepy/routers/tools/isslop.py
Normal file
@ -0,0 +1,506 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
from typing import Annotated
|
||||
|
||||
import uuid_utils
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
|
||||
from devplacepy.config import ISSLOP_MEDIA_DIR
|
||||
from devplacepy.constants import DEVII_GUEST_COOKIE
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import IsslopRunForm
|
||||
from devplacepy.responses import respond
|
||||
from devplacepy.schemas import IsslopAnalysisOut, IsslopListOut, IsslopReportOut, IsslopSourceOut
|
||||
from devplacepy.seo import base_seo_context, site_url, web_application_schema, website_schema
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.services.jobs.isslop import store
|
||||
from devplacepy.services.jobs.isslop.badge import badge_html, badge_markdown, render_badge
|
||||
from devplacepy.services.jobs.isslop.service import topic_for
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user, not_found, track_action
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
ACTIVE_STATES = (queue.PENDING, queue.RUNNING)
|
||||
GUEST_COOKIE_MAX_AGE = 31536000
|
||||
EVENT_LIMIT_MAX = 5000
|
||||
MEDIA_NAME_PATTERN = re.compile(r"^[a-f0-9]{16}\.webp$")
|
||||
SOURCE_NAME_PATTERN = re.compile(r"^s[a-f0-9]{16}\.txt$")
|
||||
SOURCE_LINE_CONTEXT = 400000
|
||||
|
||||
|
||||
def _owner(request: Request) -> tuple[str, str] | None:
|
||||
user = get_current_user(request)
|
||||
if user:
|
||||
return "user", user["uid"]
|
||||
guest = request.cookies.get(DEVII_GUEST_COOKIE)
|
||||
if guest:
|
||||
return "guest", guest
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_owner(request: Request) -> tuple[str, str, str]:
|
||||
owner = _owner(request)
|
||||
if owner:
|
||||
return owner[0], owner[1], ""
|
||||
minted = uuid_utils.uuid7().hex
|
||||
return "guest", minted, minted
|
||||
|
||||
|
||||
def _set_guest_cookie(response, minted: str) -> None:
|
||||
if minted:
|
||||
response.set_cookie(
|
||||
DEVII_GUEST_COOKIE,
|
||||
minted,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=GUEST_COOKIE_MAX_AGE,
|
||||
)
|
||||
|
||||
|
||||
def _sync_guest_history(request: Request) -> None:
|
||||
user = get_current_user(request)
|
||||
guest = request.cookies.get(DEVII_GUEST_COOKIE)
|
||||
if user and guest:
|
||||
store.claim_guest_analyses(guest, user["uid"])
|
||||
|
||||
|
||||
def _analysis_payload(row: dict) -> dict:
|
||||
uid = row.get("uid", "")
|
||||
return {
|
||||
"uid": uid,
|
||||
"status": row.get("status", ""),
|
||||
"source_url": row.get("source_url", ""),
|
||||
"source_kind": row.get("source_kind", "") or "unknown",
|
||||
"grade": row.get("grade"),
|
||||
"slop_score": row.get("slop_score"),
|
||||
"origin_score": row.get("origin_score"),
|
||||
"quality_deficit_score": row.get("quality_deficit_score"),
|
||||
"human_percent": row.get("human_percent"),
|
||||
"ai_percent": row.get("ai_percent"),
|
||||
"category": row.get("category"),
|
||||
"confidence": row.get("confidence"),
|
||||
"files_total": int(row.get("files_total") or 0),
|
||||
"files_analyzed": int(row.get("files_analyzed") or 0),
|
||||
"detected_builder": row.get("detected_builder") or None,
|
||||
"dom_slop_score": row.get("dom_slop_score"),
|
||||
"error": row.get("error_message_text") or None,
|
||||
"report_url": f"/tools/isslop/{uid}/report",
|
||||
"badge_url": f"/tools/isslop/{uid}/badge.svg",
|
||||
"events_url": f"/tools/isslop/{uid}/events",
|
||||
"topic": topic_for(uid),
|
||||
"created_at": row.get("created_at"),
|
||||
"finished_at": row.get("finished_at"),
|
||||
}
|
||||
|
||||
|
||||
SEVERITY_RANK = {"strong": 0, "medium": 1, "weak": 2}
|
||||
SIGNAL_LINES_CAP = 8
|
||||
|
||||
|
||||
def _signal_groups(signals: list) -> list:
|
||||
groups: dict[str, dict] = {}
|
||||
for signal in signals:
|
||||
if not isinstance(signal, dict):
|
||||
continue
|
||||
code = str(signal.get("code", ""))
|
||||
entry = groups.setdefault(
|
||||
code,
|
||||
{
|
||||
"code": code,
|
||||
"title": str(signal.get("title", "")),
|
||||
"severity": str(signal.get("severity", "weak")),
|
||||
"count": 0,
|
||||
"lines": [],
|
||||
},
|
||||
)
|
||||
entry["count"] += 1
|
||||
line = signal.get("line")
|
||||
if isinstance(line, int) and len(entry["lines"]) < SIGNAL_LINES_CAP:
|
||||
entry["lines"].append(line)
|
||||
return sorted(
|
||||
groups.values(),
|
||||
key=lambda group: (SEVERITY_RANK.get(group["severity"], 3), -group["count"], group["code"]),
|
||||
)
|
||||
|
||||
|
||||
def _source_url(uid: str, path: str, line: int = 0) -> str:
|
||||
url = f"/tools/isslop/{uid}/source?path={quote(path, safe='')}"
|
||||
if line > 0:
|
||||
url += f"&line={line}#L{line}"
|
||||
return url
|
||||
|
||||
|
||||
CRITERIA_URL = "/docs/isslop-checks.html"
|
||||
|
||||
|
||||
def _linkify_sources(markdown: str, uid: str, paths: set[str], signal_codes: set[str]) -> str:
|
||||
for path in sorted(paths, key=len, reverse=True):
|
||||
escaped = re.escape(path)
|
||||
markdown = re.sub(
|
||||
rf"`{escaped}:(\d+)`",
|
||||
lambda match, p=path: f"[`{p}:{match.group(1)}`]({_source_url(uid, p, int(match.group(1)))})",
|
||||
markdown,
|
||||
)
|
||||
markdown = markdown.replace(f"`{path}`", f"[`{path}`]({_source_url(uid, path)})")
|
||||
markdown = re.sub(
|
||||
rf"(?<![\w/`\(\[]){escaped}(?::(\d+))?(?![\w/`])",
|
||||
lambda match, p=path: (
|
||||
f"[{p}:{match.group(1)}]({_source_url(uid, p, int(match.group(1)))})"
|
||||
if match.group(1)
|
||||
else f"[{p}]({_source_url(uid, p)})"
|
||||
),
|
||||
markdown,
|
||||
)
|
||||
for code in sorted(signal_codes, key=len, reverse=True):
|
||||
escaped = re.escape(code)
|
||||
markdown = markdown.replace(f"`{code}`", f"[`{code}`]({CRITERIA_URL})")
|
||||
markdown = re.sub(
|
||||
rf"(?<![\w`\[]){escaped}(?![\w`])",
|
||||
f"[{code}]({CRITERIA_URL})",
|
||||
markdown,
|
||||
)
|
||||
return markdown
|
||||
|
||||
|
||||
def _badge_info(request: Request, uid: str) -> dict:
|
||||
base = site_url(request).rstrip("/")
|
||||
badge_url = f"{base}/tools/isslop/{uid}/badge.svg"
|
||||
report_url = f"{base}/tools/isslop/{uid}/report"
|
||||
return {
|
||||
"badge_url": badge_url,
|
||||
"report_url": report_url,
|
||||
"markdown": badge_markdown(badge_url, report_url),
|
||||
"html": badge_html(badge_url, report_url),
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def isslop_page(request: Request):
|
||||
user = get_current_user(request)
|
||||
_sync_guest_history(request)
|
||||
base = site_url(request)
|
||||
description = (
|
||||
"Measure how a codebase or website was made: untouched AI defaults, AI steered by a "
|
||||
"knowing hand, or work no model would ever produce. Transparent, reproducible analysis "
|
||||
"with a shareable authenticity badge."
|
||||
)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="AI Usage Analyzer",
|
||||
description=description,
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Tools", "url": "/tools"},
|
||||
{"name": "AI Usage Analyzer", "url": "/tools/isslop"},
|
||||
],
|
||||
schemas=[
|
||||
website_schema(base),
|
||||
web_application_schema("AI Usage Analyzer", description, "/tools/isslop", base),
|
||||
],
|
||||
)
|
||||
minted = "" if _owner(request) else uuid_utils.uuid7().hex
|
||||
response = templates.TemplateResponse(
|
||||
request,
|
||||
"tools/isslop.html",
|
||||
{**seo_ctx, "request": request, "user": user},
|
||||
)
|
||||
_set_guest_cookie(response, minted)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def isslop_run(request: Request, data: Annotated[IsslopRunForm, Depends(json_or_form(IsslopRunForm))]):
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
owner_kind, owner_id, minted = _ensure_owner(request)
|
||||
active = [
|
||||
job
|
||||
for job in queue.list_jobs(kind="isslop", owner=(owner_kind, owner_id))
|
||||
if job.get("status") in ACTIVE_STATES
|
||||
]
|
||||
if active:
|
||||
audit.record(
|
||||
request,
|
||||
"isslop.run.request",
|
||||
result="denied",
|
||||
summary=f"AI usage analysis denied for {data.url}: analysis already running",
|
||||
metadata={"target": data.url, "reason": "active_job", "uid": active[0]["uid"]},
|
||||
links=[audit.job(active[0]["uid"])],
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": {
|
||||
"status": 429,
|
||||
"message": "You already have an analysis running. Wait for it to finish.",
|
||||
"uid": active[0]["uid"],
|
||||
}
|
||||
},
|
||||
status_code=429,
|
||||
)
|
||||
uid = queue.enqueue(
|
||||
"isslop",
|
||||
{"url": data.url},
|
||||
owner_kind,
|
||||
owner_id,
|
||||
f"AI usage: {data.url}"[:64],
|
||||
)
|
||||
store.create_analysis(uid, data.url, owner_kind, owner_id)
|
||||
audit.record(
|
||||
request,
|
||||
"isslop.run.request",
|
||||
summary=f"requested AI usage analysis of {data.url}",
|
||||
metadata={"target": data.url},
|
||||
links=[audit.job(uid)],
|
||||
)
|
||||
if owner_kind == "user":
|
||||
track_action(owner_id, "isslop")
|
||||
response = JSONResponse(
|
||||
{
|
||||
"uid": uid,
|
||||
"status_url": f"/tools/isslop/{uid}",
|
||||
"events_url": f"/tools/isslop/{uid}/events",
|
||||
"report_url": f"/tools/isslop/{uid}/report",
|
||||
"topic": topic_for(uid),
|
||||
}
|
||||
)
|
||||
_set_guest_cookie(response, minted)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def isslop_list(request: Request, limit: int = store.LIST_LIMIT_DEFAULT):
|
||||
_sync_guest_history(request)
|
||||
owner = _owner(request)
|
||||
rows = []
|
||||
if owner:
|
||||
rows = store.list_analyses(owner[0], owner[1], min(max(1, limit), 200))
|
||||
return JSONResponse(
|
||||
IsslopListOut.model_validate(
|
||||
{"analyses": [_analysis_payload(row) for row in rows]}
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{uid}")
|
||||
async def isslop_status(request: Request, uid: str):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
return JSONResponse(
|
||||
IsslopAnalysisOut.model_validate(_analysis_payload(row)).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{uid}/events")
|
||||
async def isslop_events(request: Request, uid: str, after: int = 0, limit: int = store.EVENT_LIMIT_DEFAULT):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
rows = store.events_for(uid, max(0, after), min(max(1, limit), EVENT_LIMIT_MAX))
|
||||
events = [
|
||||
{
|
||||
"seq": event["seq"],
|
||||
"kind": event["kind"],
|
||||
"message": event["message"],
|
||||
"data": store.decode_json(event.get("payload"), {}),
|
||||
"created_at": event["created_at"],
|
||||
}
|
||||
for event in rows
|
||||
]
|
||||
return JSONResponse({"uid": uid, "status": row.get("status", ""), "events": events})
|
||||
|
||||
|
||||
@router.get("/{uid}/report")
|
||||
async def isslop_report(request: Request, uid: str):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
report = store.get_report(uid)
|
||||
files = []
|
||||
linkable_paths: set[str] = set()
|
||||
signal_codes: set[str] = set()
|
||||
for item in store.file_results_for(uid):
|
||||
signals = store.decode_json(item.get("signals"), [])
|
||||
for signal in signals:
|
||||
if isinstance(signal, dict) and signal.get("code"):
|
||||
signal_codes.add(str(signal["code"]))
|
||||
source_name = str(item.get("source") or "")
|
||||
has_source = bool(source_name and SOURCE_NAME_PATTERN.match(source_name))
|
||||
path = item.get("path", "")
|
||||
if has_source:
|
||||
linkable_paths.add(path)
|
||||
files.append(
|
||||
{
|
||||
"path": path,
|
||||
"language": item.get("language", "unknown"),
|
||||
"lines": int(item.get("lines") or 0),
|
||||
"origin_score": float(item.get("origin_score") or 0.0),
|
||||
"quality_deficit_score": float(item.get("quality_deficit_score") or 0.0),
|
||||
"category": item.get("category", "uncertain"),
|
||||
"signals": signals,
|
||||
"signal_groups": _signal_groups(signals),
|
||||
"source_url": _source_url(uid, path) if has_source else None,
|
||||
}
|
||||
)
|
||||
images = [
|
||||
{
|
||||
"path": item.get("path", ""),
|
||||
"ai_probability": float(item.get("ai_probability") or 0.0),
|
||||
"grade": item.get("grade", "n/a"),
|
||||
"verdict": item.get("verdict", "uncertain"),
|
||||
"image_kind": item.get("image_kind", "image"),
|
||||
"tells": store.decode_json(item.get("tells"), []),
|
||||
"description": item.get("description", ""),
|
||||
"thumb_url": (
|
||||
f"/tools/isslop/{uid}/media/{item['thumb']}"
|
||||
if item.get("thumb") and MEDIA_NAME_PATTERN.match(str(item["thumb"]))
|
||||
else None
|
||||
),
|
||||
}
|
||||
for item in store.image_results_for(uid)
|
||||
]
|
||||
dom_pages = [
|
||||
{
|
||||
"url": item.get("url", ""),
|
||||
"detected_builder": item.get("detected_builder") or None,
|
||||
"signal_count": int(item.get("signal_count") or 0),
|
||||
"signals": store.decode_json(item.get("signals"), []),
|
||||
"screenshot_url": (
|
||||
f"/tools/isslop/{uid}/media/{item['screenshot']}"
|
||||
if item.get("screenshot") and MEDIA_NAME_PATTERN.match(str(item["screenshot"]))
|
||||
else None
|
||||
),
|
||||
}
|
||||
for item in store.dom_results_for(uid)
|
||||
]
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="AI usage analysis report",
|
||||
description=f"Authenticity analysis of {row.get('source_url', '')}",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Tools", "url": "/tools"},
|
||||
{"name": "AI Usage Analyzer", "url": "/tools/isslop"},
|
||||
{"name": "Report", "url": f"/tools/isslop/{uid}/report"},
|
||||
],
|
||||
)
|
||||
context = {
|
||||
**seo_ctx,
|
||||
**_analysis_payload(row),
|
||||
"content_hash": row.get("content_hash"),
|
||||
"markdown": _linkify_sources(report.get("markdown", ""), uid, linkable_paths, signal_codes) if report else "",
|
||||
"generator_model": report.get("model_used", "") if report else "",
|
||||
"generated_at": report.get("generated_at") if report else None,
|
||||
"badge": _badge_info(request, uid),
|
||||
"files": files,
|
||||
"images": images,
|
||||
"dom_pages": dom_pages,
|
||||
"request": request,
|
||||
"user": get_current_user(request),
|
||||
}
|
||||
return respond(request, "tools/isslop_report.html", context, model=IsslopReportOut)
|
||||
|
||||
|
||||
@router.get("/{uid}/report.md")
|
||||
async def isslop_report_markdown(request: Request, uid: str):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
report = store.get_report(uid)
|
||||
if not report:
|
||||
raise not_found("Report not yet generated for this analysis")
|
||||
return Response(
|
||||
content=report["markdown"],
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
headers={"content-disposition": f'attachment; filename="ai-usage-report-{uid}.md"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{uid}/source")
|
||||
async def isslop_source(request: Request, uid: str, path: str, line: int = 0):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
result = store.file_result_for(uid, path)
|
||||
source_name = str(result.get("source") or "") if result else ""
|
||||
if not result or not SOURCE_NAME_PATTERN.match(source_name):
|
||||
raise not_found("Source not available for this file")
|
||||
source_path = (store.media_dir_for(uid) / source_name).resolve()
|
||||
if not source_path.is_relative_to(ISSLOP_MEDIA_DIR.resolve()) or not source_path.is_file():
|
||||
raise not_found("Source not available for this file")
|
||||
text = source_path.read_text(encoding="utf-8", errors="replace")
|
||||
signals = store.decode_json(result.get("signals"), [])
|
||||
marked: dict[int, list] = {}
|
||||
for signal in signals:
|
||||
if isinstance(signal, dict) and isinstance(signal.get("line"), int) and signal["line"] > 0:
|
||||
marked.setdefault(signal["line"], []).append(signal)
|
||||
source_lines = text.split("\n")
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title=f"Source: {path}",
|
||||
description=f"Annotated source of {path} from the AI usage analysis",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Tools", "url": "/tools"},
|
||||
{"name": "AI Usage Analyzer", "url": "/tools/isslop"},
|
||||
{"name": "Report", "url": f"/tools/isslop/{uid}/report"},
|
||||
{"name": "Source", "url": _source_url(uid, path)},
|
||||
],
|
||||
)
|
||||
context = {
|
||||
**seo_ctx,
|
||||
"uid": uid,
|
||||
"path": path,
|
||||
"language": result.get("language", "unknown"),
|
||||
"category": result.get("category", "uncertain"),
|
||||
"origin_score": float(result.get("origin_score") or 0.0),
|
||||
"quality_deficit_score": float(result.get("quality_deficit_score") or 0.0),
|
||||
"source": text,
|
||||
"truncated": len(text) >= SOURCE_LINE_CONTEXT,
|
||||
"signals": signals,
|
||||
"source_lines": source_lines,
|
||||
"marked_lines": marked,
|
||||
"focus_line": max(0, line),
|
||||
"report_url": f"/tools/isslop/{uid}/report",
|
||||
"request": request,
|
||||
"user": get_current_user(request),
|
||||
}
|
||||
return respond(request, "tools/isslop_source.html", context, model=IsslopSourceOut)
|
||||
|
||||
|
||||
@router.get("/{uid}/media/{name}")
|
||||
async def isslop_media(request: Request, uid: str, name: str):
|
||||
row = store.get_analysis(uid)
|
||||
if not row or not MEDIA_NAME_PATTERN.match(name):
|
||||
raise not_found("Image not available")
|
||||
root = ISSLOP_MEDIA_DIR.resolve()
|
||||
path = (store.media_dir_for(uid) / name).resolve()
|
||||
if not path.is_relative_to(root) or not path.is_file():
|
||||
raise not_found("Image not available")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="image/webp",
|
||||
headers={"cache-control": "public, max-age=86400"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{uid}/badge.svg")
|
||||
async def isslop_badge(request: Request, uid: str):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
report_url = _badge_info(request, uid)["report_url"]
|
||||
svg = render_badge(row.get("human_percent"), row.get("grade"), report_url)
|
||||
return Response(
|
||||
content=svg,
|
||||
media_type="image/svg+xml",
|
||||
headers={"cache-control": "no-cache, max-age=300"},
|
||||
)
|
||||
637
devplacepy/services/containers/api.py.bak
Normal file
637
devplacepy/services/containers/api.py.bak
Normal file
@ -0,0 +1,637 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy import config, project_files, stealth
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.backend.base import (
|
||||
WORKSPACE_MOUNT,
|
||||
Mount,
|
||||
PortMapping,
|
||||
RunSpec,
|
||||
)
|
||||
from devplacepy.services.containers.runtime import get_backend
|
||||
from devplacepy.services.devii.tasks.schedule import (
|
||||
Schedule,
|
||||
now_utc,
|
||||
to_iso,
|
||||
)
|
||||
|
||||
IMAGE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$")
|
||||
INGRESS_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
MEM_RE = re.compile(r"^\d+(\.\d+)?[bkmgBKMG]?$")
|
||||
CPU_RE = re.compile(r"^\d+(\.\d+)?$")
|
||||
ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
INSTANCE_LABEL = "devplace.instance"
|
||||
PROJECT_LABEL = "devplace.project"
|
||||
HOST_PORT_MIN = 20001
|
||||
HOST_PORT_MAX = 65535
|
||||
BOOT_LANGUAGES = ("none", "python", "bash")
|
||||
BOOT_SCRIPT_FILES = {"python": ".devplace_boot.py", "bash": ".devplace_boot.sh"}
|
||||
BOOT_SCRIPT_RUNNERS = {"python": "python", "bash": "bash"}
|
||||
MAX_BOOT_SCRIPT_CHARS = 100_000
|
||||
|
||||
|
||||
class ContainerError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _validate_limits(cpu_limit: str, mem_limit: str) -> None:
|
||||
if cpu_limit and not CPU_RE.match(str(cpu_limit)):
|
||||
raise ContainerError("cpu limit must be a number, e.g. 1 or 1.5")
|
||||
if mem_limit and not MEM_RE.match(str(mem_limit)):
|
||||
raise ContainerError("memory limit must look like 512m, 1g, or a byte count")
|
||||
|
||||
|
||||
def validate_run_as(run_as_uid) -> str:
|
||||
uid = str(run_as_uid or "").strip()
|
||||
if not uid:
|
||||
return ""
|
||||
from devplacepy import database
|
||||
|
||||
user = database.get_users_by_uids([uid]).get(uid)
|
||||
if not user:
|
||||
raise ContainerError(f"run-as user not found: {uid}")
|
||||
return uid
|
||||
|
||||
|
||||
def validate_boot(boot_language, boot_script) -> tuple:
|
||||
language = str(boot_language or "none").strip().lower() or "none"
|
||||
if language not in BOOT_LANGUAGES:
|
||||
raise ContainerError(
|
||||
f"boot language must be one of {', '.join(BOOT_LANGUAGES)}"
|
||||
)
|
||||
script = str(boot_script or "")
|
||||
if language == "none":
|
||||
script = ""
|
||||
if len(script) > MAX_BOOT_SCRIPT_CHARS:
|
||||
raise ContainerError(
|
||||
f"boot script exceeds the {MAX_BOOT_SCRIPT_CHARS}-character limit"
|
||||
)
|
||||
if language != "none" and not script.strip():
|
||||
raise ContainerError("boot script is required when a boot language is set")
|
||||
return language, script
|
||||
|
||||
|
||||
def parse_ports(value) -> list:
|
||||
ports = []
|
||||
if not value:
|
||||
return ports
|
||||
items = (
|
||||
value if isinstance(value, list) else str(value).replace(",", "\n").splitlines()
|
||||
)
|
||||
for item in items:
|
||||
item = str(item).strip()
|
||||
if not item:
|
||||
continue
|
||||
proto = "tcp"
|
||||
if "/" in item:
|
||||
item, proto = item.split("/", 1)
|
||||
if ":" in item:
|
||||
host, container = item.split(":", 1)
|
||||
else:
|
||||
host, container = "0", item
|
||||
if not host.isdigit() or not container.isdigit():
|
||||
raise ContainerError(
|
||||
f"port '{item}' must be numeric host:container or a bare container port"
|
||||
)
|
||||
ports.append(PortMapping(int(host), int(container), proto.strip() or "tcp"))
|
||||
return ports
|
||||
|
||||
|
||||
def used_host_ports() -> set:
|
||||
ports = set()
|
||||
for instance in store.all_instances():
|
||||
for mapping in json.loads(instance.get("ports_json") or "[]"):
|
||||
host = int(mapping.get("host") or 0)
|
||||
if host:
|
||||
ports.add(host)
|
||||
return ports
|
||||
|
||||
|
||||
def _host_port_free(port: int) -> bool:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
sock.bind(("0.0.0.0", port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def allocate_host_port(reserved: set) -> int:
|
||||
for port in range(HOST_PORT_MIN, HOST_PORT_MAX + 1):
|
||||
if port in reserved:
|
||||
continue
|
||||
if _host_port_free(port):
|
||||
return port
|
||||
raise ContainerError(
|
||||
f"no free host port available in range {HOST_PORT_MIN}-{HOST_PORT_MAX}"
|
||||
)
|
||||
|
||||
|
||||
def assign_host_ports(port_list: list) -> list:
|
||||
reserved = used_host_ports()
|
||||
assigned = []
|
||||
for mapping in port_list:
|
||||
host = mapping.host
|
||||
if not host:
|
||||
host = allocate_host_port(reserved)
|
||||
elif host in reserved:
|
||||
raise ContainerError(
|
||||
f"host port {host} is already published by another instance"
|
||||
)
|
||||
reserved.add(host)
|
||||
assigned.append(PortMapping(host, mapping.container, mapping.proto))
|
||||
return assigned
|
||||
|
||||
|
||||
def parse_env(value) -> dict:
|
||||
env = {}
|
||||
if not value:
|
||||
return env
|
||||
if isinstance(value, dict):
|
||||
items = value.items()
|
||||
else:
|
||||
items = (line.split("=", 1) for line in str(value).splitlines() if "=" in line)
|
||||
for key, val in items:
|
||||
key = str(key).strip()
|
||||
if not ENV_KEY_RE.match(key):
|
||||
raise ContainerError(f"invalid environment variable name: {key}")
|
||||
env[key] = str(val)
|
||||
return env
|
||||
|
||||
|
||||
# ---------------- instances ----------------
|
||||
|
||||
|
||||
def validate_ingress(slug: str, port, port_list) -> tuple:
|
||||
slug = (slug or "").strip().lower()
|
||||
if not slug:
|
||||
return "", 0
|
||||
if not INGRESS_SLUG_RE.match(slug):
|
||||
raise ContainerError(
|
||||
"ingress slug must be lowercase letters, digits, or '-' (max 63 chars)"
|
||||
)
|
||||
for other in store.all_instances():
|
||||
if other.get("ingress_slug") == slug:
|
||||
raise ContainerError(f"ingress slug '{slug}' is already in use")
|
||||
ingress_port = int(port) if port else 0
|
||||
container_ports = {p.container for p in port_list}
|
||||
if ingress_port and ingress_port not in container_ports:
|
||||
raise ContainerError(
|
||||
f"ingress_port {ingress_port} must be one of the container ports you mapped"
|
||||
)
|
||||
if not ingress_port and len(container_ports) != 1:
|
||||
raise ContainerError(
|
||||
"set ingress_port to choose which mapped container port to publish"
|
||||
)
|
||||
return slug, ingress_port
|
||||
|
||||
|
||||
async def create_instance(
|
||||
project: dict,
|
||||
*,
|
||||
name: str,
|
||||
boot_command: str = "",
|
||||
boot_language: str = "none",
|
||||
boot_script: str = "",
|
||||
run_as_uid: str = "",
|
||||
start_on_boot: bool = False,
|
||||
env="",
|
||||
cpu_limit: str = "",
|
||||
mem_limit: str = "",
|
||||
ports="",
|
||||
volumes="",
|
||||
restart_policy: str = "never",
|
||||
autostart: bool = True,
|
||||
ingress_slug: str = "",
|
||||
ingress_port=None,
|
||||
actor=("system", "system"),
|
||||
) -> dict:
|
||||
if not await get_backend().image_exists(config.CONTAINER_IMAGE):
|
||||
raise ContainerError(
|
||||
f"the '{config.CONTAINER_IMAGE}' image is not built - run 'make ppy'"
|
||||
)
|
||||
if restart_policy not in store.RESTART_POLICIES:
|
||||
raise ContainerError(
|
||||
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
|
||||
)
|
||||
_validate_limits(cpu_limit, mem_limit)
|
||||
run_as_uid = validate_run_as(run_as_uid)
|
||||
boot_language, boot_script = validate_boot(boot_language, boot_script)
|
||||
port_list = assign_host_ports(parse_ports(ports))
|
||||
env_map = parse_env(env)
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ContainerError("instance name is required")
|
||||
ingress_slug, ingress_port = validate_ingress(ingress_slug, ingress_port, port_list)
|
||||
|
||||
workspace = Path(config.CONTAINER_WORKSPACES_DIR) / project["uid"]
|
||||
await asyncio.to_thread(
|
||||
project_files.export_to_dir, project["uid"], "", str(workspace)
|
||||
)
|
||||
|
||||
row = {
|
||||
"project_uid": project["uid"],
|
||||
"created_by": actor[1] if actor and actor[0] == "user" else "",
|
||||
"owner_uid": project.get("user_uid", ""),
|
||||
"run_as_uid": run_as_uid,
|
||||
"name": name,
|
||||
"boot_command": boot_command or "",
|
||||
"boot_language": boot_language,
|
||||
"boot_script": boot_script,
|
||||
"start_on_boot": 1 if start_on_boot else 0,
|
||||
"env_json": json.dumps(env_map),
|
||||
"cpu_limit": str(cpu_limit or ""),
|
||||
"mem_limit": str(mem_limit or ""),
|
||||
"ports_json": json.dumps(
|
||||
[
|
||||
{"host": p.host, "container": p.container, "proto": p.proto}
|
||||
for p in port_list
|
||||
]
|
||||
),
|
||||
"volumes_json": volumes
|
||||
if isinstance(volumes, str)
|
||||
else json.dumps(volumes or []),
|
||||
"restart_policy": restart_policy,
|
||||
"ingress_slug": ingress_slug,
|
||||
"ingress_port": ingress_port,
|
||||
"desired_state": store.DESIRED_RUNNING if autostart else store.DESIRED_STOPPED,
|
||||
"status": store.ST_CREATED,
|
||||
"workspace_dir": str(workspace),
|
||||
}
|
||||
instance = store.create_instance(row)
|
||||
store.record_event(
|
||||
instance, "created", actor[0], actor[1], {"image": config.CONTAINER_IMAGE}
|
||||
)
|
||||
if actor and actor[0] == "user":
|
||||
from devplacepy.utils import track_action
|
||||
|
||||
track_action(actor[1], "container")
|
||||
return instance
|
||||
|
||||
|
||||
def set_desired_state(
|
||||
instance: dict, desired: str, *, actor=("system", "system")
|
||||
) -> dict:
|
||||
if desired not in (
|
||||
store.DESIRED_RUNNING,
|
||||
store.DESIRED_STOPPED,
|
||||
store.DESIRED_PAUSED,
|
||||
):
|
||||
raise ContainerError("desired state must be running, stopped, or paused")
|
||||
store.update_instance(instance["uid"], {"desired_state": desired})
|
||||
store.record_event(instance, f"desire_{desired}", actor[0], actor[1])
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def request_restart(instance: dict, *, actor=("system", "system")) -> dict:
|
||||
store.update_instance(
|
||||
instance["uid"],
|
||||
{"desired_state": store.DESIRED_RUNNING, "status": store.ST_RESTARTING},
|
||||
)
|
||||
store.record_event(instance, "restart", actor[0], actor[1])
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def mark_for_removal(instance: dict, *, actor=("system", "system")) -> None:
|
||||
store.update_instance(
|
||||
instance["uid"],
|
||||
{"desired_state": store.DESIRED_STOPPED, "status": store.ST_REMOVING},
|
||||
)
|
||||
store.record_event(instance, "remove", actor[0], actor[1])
|
||||
|
||||
|
||||
def update_instance_config(
|
||||
instance: dict,
|
||||
*,
|
||||
run_as_uid=None,
|
||||
boot_language=None,
|
||||
boot_script=None,
|
||||
boot_command=None,
|
||||
restart_policy=None,
|
||||
start_on_boot=None,
|
||||
cpu_limit=None,
|
||||
mem_limit=None,
|
||||
actor=("system", "system"),
|
||||
) -> dict:
|
||||
changes: dict = {}
|
||||
if run_as_uid is not None:
|
||||
changes["run_as_uid"] = validate_run_as(run_as_uid)
|
||||
if boot_language is not None or boot_script is not None:
|
||||
language = (
|
||||
boot_language
|
||||
if boot_language is not None
|
||||
else instance.get("boot_language", "none")
|
||||
)
|
||||
script = (
|
||||
boot_script if boot_script is not None else instance.get("boot_script", "")
|
||||
)
|
||||
language, script = validate_boot(language, script)
|
||||
changes["boot_language"] = language
|
||||
changes["boot_script"] = script
|
||||
if boot_command is not None:
|
||||
changes["boot_command"] = str(boot_command or "")[:500]
|
||||
if restart_policy is not None:
|
||||
if restart_policy not in store.RESTART_POLICIES:
|
||||
raise ContainerError(
|
||||
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
|
||||
)
|
||||
changes["restart_policy"] = restart_policy
|
||||
if start_on_boot is not None:
|
||||
changes["start_on_boot"] = 1 if start_on_boot else 0
|
||||
if cpu_limit is not None or mem_limit is not None:
|
||||
cpu = cpu_limit if cpu_limit is not None else instance.get("cpu_limit", "")
|
||||
mem = mem_limit if mem_limit is not None else instance.get("mem_limit", "")
|
||||
_validate_limits(cpu, mem)
|
||||
changes["cpu_limit"] = str(cpu or "")
|
||||
changes["mem_limit"] = str(mem or "")
|
||||
if not changes:
|
||||
return store.get_instance(instance["uid"])
|
||||
store.update_instance(instance["uid"], changes)
|
||||
store.record_event(
|
||||
instance, "configure", actor[0], actor[1], {"fields": sorted(changes)}
|
||||
)
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def set_start_on_boot(
|
||||
instance: dict, enabled: bool, *, actor=("system", "system")
|
||||
) -> dict:
|
||||
store.update_instance(instance["uid"], {"start_on_boot": 1 if enabled else 0})
|
||||
store.record_event(
|
||||
instance, "start_on_boot", actor[0], actor[1], {"enabled": bool(enabled)}
|
||||
)
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def materialize_boot_script(instance: dict) -> None:
|
||||
language = (instance.get("boot_language") or "none").strip().lower()
|
||||
workspace = instance.get("workspace_dir")
|
||||
if not workspace:
|
||||
return
|
||||
for filename in BOOT_SCRIPT_FILES.values():
|
||||
stale = Path(workspace) / filename
|
||||
if stale.is_file():
|
||||
try:
|
||||
stale.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
if language not in BOOT_SCRIPT_FILES:
|
||||
return
|
||||
script = instance.get("boot_script") or ""
|
||||
if not script.strip():
|
||||
return
|
||||
target = Path(workspace) / BOOT_SCRIPT_FILES[language]
|
||||
try:
|
||||
Path(workspace).mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(script, encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def pravda_env(instance: dict) -> dict:
|
||||
from devplacepy import database, seo
|
||||
|
||||
base_url = seo.public_base_url()
|
||||
api_key = ""
|
||||
user_uid = instance.get("owner_uid") or ""
|
||||
for uid in (
|
||||
instance.get("run_as_uid"),
|
||||
instance.get("created_by"),
|
||||
instance.get("owner_uid"),
|
||||
):
|
||||
if not uid:
|
||||
continue
|
||||
user = database.get_users_by_uids([uid]).get(uid)
|
||||
if user and user.get("api_key"):
|
||||
api_key = user["api_key"]
|
||||
break
|
||||
slug = instance.get("ingress_slug") or ""
|
||||
ingress_url = (f"{base_url}/p/{slug}" if base_url else f"/p/{slug}") if slug else ""
|
||||
return {
|
||||
"PRAVDA_BASE_URL": base_url,
|
||||
"PRAVDA_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
|
||||
"PRAVDA_API_KEY": api_key,
|
||||
"PRAVDA_USER_UID": instance.get("run_as_uid") or user_uid,
|
||||
"PRAVDA_CONTAINER_NAME": instance.get("name") or "",
|
||||
"PRAVDA_CONTAINER_UID": instance.get("uid") or "",
|
||||
"PRAVDA_INGRESS_URL": ingress_url,
|
||||
}
|
||||
|
||||
|
||||
def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
|
||||
env = {**json.loads(instance.get("env_json") or "{}"), **pravda_env(instance)}
|
||||
ports = [
|
||||
PortMapping(p["host"], p["container"], p.get("proto", "tcp"))
|
||||
for p in json.loads(instance.get("ports_json") or "[]")
|
||||
]
|
||||
mounts = [Mount(instance["workspace_dir"], WORKSPACE_MOUNT, "rw")]
|
||||
for extra in json.loads(instance.get("volumes_json") or "[]"):
|
||||
if isinstance(extra, dict) and extra.get("host") and extra.get("container"):
|
||||
mounts.append(
|
||||
Mount(extra["host"], extra["container"], extra.get("mode", "rw"))
|
||||
)
|
||||
language = (instance.get("boot_language") or "none").strip().lower()
|
||||
boot = (instance.get("boot_command") or "").strip()
|
||||
if language in BOOT_SCRIPT_FILES and (instance.get("boot_script") or "").strip():
|
||||
script_path = f"{WORKSPACE_MOUNT}/{BOOT_SCRIPT_FILES[language]}"
|
||||
command = [BOOT_SCRIPT_RUNNERS[language], script_path]
|
||||
elif boot:
|
||||
command = ["/bin/sh", "-c", boot]
|
||||
else:
|
||||
command = ["sleep", "infinity"]
|
||||
return RunSpec(
|
||||
image=image_tag,
|
||||
name=instance["slug"],
|
||||
labels={
|
||||
INSTANCE_LABEL: instance["uid"],
|
||||
PROJECT_LABEL: instance["project_uid"],
|
||||
},
|
||||
env=env,
|
||||
cpu_limit=instance.get("cpu_limit", ""),
|
||||
mem_limit=instance.get("mem_limit", ""),
|
||||
ports=ports,
|
||||
mounts=mounts,
|
||||
restart_policy=instance.get("restart_policy", "never"),
|
||||
command=command,
|
||||
)
|
||||
|
||||
|
||||
async def sync_workspace(instance: dict, user: dict) -> dict:
|
||||
workspace = instance.get("workspace_dir")
|
||||
if not workspace:
|
||||
raise ContainerError("instance has no workspace")
|
||||
counts = await asyncio.to_thread(
|
||||
project_files.sync_dir_bidirectional, instance["project_uid"], workspace, user
|
||||
)
|
||||
store.record_event(
|
||||
instance,
|
||||
"sync",
|
||||
"user",
|
||||
user["uid"],
|
||||
{"exported": counts["exported"], "imported": counts["imported"]},
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def sync_bidirectional_sync(instance: dict, user: dict) -> dict:
|
||||
workspace = instance.get("workspace_dir")
|
||||
if not workspace:
|
||||
return {"exported": 0, "imported": 0}
|
||||
counts = project_files.sync_dir_bidirectional(
|
||||
instance["project_uid"], workspace, user
|
||||
)
|
||||
if counts["exported"] or counts["imported"]:
|
||||
store.record_event(
|
||||
instance,
|
||||
"sync",
|
||||
"service",
|
||||
"system",
|
||||
{"exported": counts["exported"], "imported": counts["imported"]},
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def add_schedule(instance: dict, action: str, schedule: Schedule) -> dict:
|
||||
if action not in ("start", "stop"):
|
||||
raise ContainerError("schedule action must be start or stop")
|
||||
first = schedule.first_run(now_utc())
|
||||
return store.create_schedule(instance, action, schedule.columns(), to_iso(first))
|
||||
|
||||
|
||||
# ---------------- aggregation ----------------
|
||||
|
||||
|
||||
def _percentile(values: list, pct: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
ordered = sorted(values)
|
||||
index = min(len(ordered) - 1, int(round((pct / 100.0) * (len(ordered) - 1))))
|
||||
return float(ordered[index])
|
||||
|
||||
|
||||
def instance_stats(instance_uid: str) -> dict:
|
||||
metrics = store.recent_metrics(instance_uid, limit=720)
|
||||
cpu = [m.get("cpu_pct", 0) for m in metrics]
|
||||
mem = [m.get("mem_bytes", 0) for m in metrics]
|
||||
return {
|
||||
"samples": len(metrics),
|
||||
"cpu_avg": round(sum(cpu) / len(cpu), 2) if cpu else 0.0,
|
||||
"cpu_p95": round(_percentile(cpu, 95), 2),
|
||||
"mem_max": max(mem) if mem else 0,
|
||||
"mem_avg": int(sum(mem) / len(mem)) if mem else 0,
|
||||
}
|
||||
|
||||
|
||||
def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
|
||||
try:
|
||||
with stealth.stealth_sync_client(timeout=timeout) as client:
|
||||
response = client.get(f"http://{host}:{port}/")
|
||||
return f"HTTP {response.status_code}"
|
||||
except Exception as exc: # noqa: BLE001 - diagnostic, any failure is informative
|
||||
return f"unreachable: {type(exc).__name__}"
|
||||
|
||||
|
||||
def _net_entry(data: dict) -> dict:
|
||||
net = (data or {}).get("NetworkSettings") or {}
|
||||
if net.get("IPAddress") or net.get("Gateway"):
|
||||
return net
|
||||
for entry in (net.get("Networks") or {}).values():
|
||||
if entry and (entry.get("IPAddress") or entry.get("Gateway")):
|
||||
return entry
|
||||
return {}
|
||||
|
||||
|
||||
def container_ip_from_inspect(data: dict) -> str:
|
||||
return (_net_entry(data).get("IPAddress") or "").strip()
|
||||
|
||||
|
||||
def container_gateway_from_inspect(data: dict) -> str:
|
||||
return (_net_entry(data).get("Gateway") or "").strip()
|
||||
|
||||
|
||||
def _ingress_container_port(instance: dict, port_maps: list) -> int:
|
||||
ingress_port = int(instance.get("ingress_port") or 0)
|
||||
if ingress_port:
|
||||
for mapping in port_maps:
|
||||
if int(mapping.get("container") or 0) == ingress_port:
|
||||
return ingress_port
|
||||
return 0
|
||||
return int(port_maps[0].get("container") or 0) if port_maps else 0
|
||||
|
||||
|
||||
def _host_port_for(port_maps: list, container_port: int) -> int:
|
||||
for mapping in port_maps:
|
||||
if int(mapping.get("container") or 0) == container_port:
|
||||
return int(mapping.get("host") or 0)
|
||||
return 0
|
||||
|
||||
|
||||
def proxy_target(instance: dict) -> tuple:
|
||||
port_maps = json.loads(instance.get("ports_json") or "[]")
|
||||
container_port = _ingress_container_port(instance, port_maps)
|
||||
if not container_port:
|
||||
return None, None
|
||||
host_port = _host_port_for(port_maps, container_port)
|
||||
if config.CONTAINER_PROXY_HOST:
|
||||
return (config.CONTAINER_PROXY_HOST, host_port) if host_port else (None, None)
|
||||
if not host_port:
|
||||
return None, None
|
||||
gateway = (instance.get("container_gateway") or "").strip()
|
||||
return (gateway or "127.0.0.1", host_port)
|
||||
|
||||
|
||||
def instance_runtime(instance: dict) -> dict:
|
||||
boot = (instance.get("boot_command") or "").strip()
|
||||
port_maps = json.loads(instance.get("ports_json") or "[]")
|
||||
container_ip = (instance.get("container_ip") or "").strip()
|
||||
container_gateway = (instance.get("container_gateway") or "").strip()
|
||||
target_host, target_port = proxy_target(instance)
|
||||
probe_host = config.CONTAINER_PROXY_HOST or container_gateway or "127.0.0.1"
|
||||
ports = []
|
||||
for mapping in port_maps:
|
||||
host_port = int(mapping.get("host") or 0)
|
||||
ports.append(
|
||||
{
|
||||
"container": int(mapping.get("container") or 0),
|
||||
"host": host_port,
|
||||
"proto": mapping.get("proto", "tcp"),
|
||||
"reachable": _port_reachable(probe_host, host_port)
|
||||
if host_port
|
||||
else False,
|
||||
}
|
||||
)
|
||||
ingress_serving = (
|
||||
_http_probe(target_host, target_port)
|
||||
if target_host and target_port
|
||||
else "no ingress port mapped"
|
||||
)
|
||||
return {
|
||||
"command": boot or "image CMD (no boot_command set)",
|
||||
"ports": ports,
|
||||
"container_ip": container_ip,
|
||||
"container_gateway": container_gateway,
|
||||
"ingress_target": (
|
||||
f"{target_host}:{target_port}" if target_host and target_port else ""
|
||||
),
|
||||
"ingress_port": int(instance.get("ingress_port") or 0),
|
||||
"ingress_serving": ingress_serving,
|
||||
"status_ok_for_ingress": instance.get("status") == store.ST_RUNNING,
|
||||
"restart_count": int(instance.get("restart_count") or 0),
|
||||
"exit_code": instance.get("exit_code"),
|
||||
"container_id": (instance.get("container_id") or "")[:12],
|
||||
}
|
||||
119
devplacepy/services/containers/files/.vimrc.bak
Normal file
119
devplacepy/services/containers/files/.vimrc.bak
Normal file
@ -0,0 +1,119 @@
|
||||
" retoor <retoor@molodetz.nl>
|
||||
" Self-contained config for the ppy container. No external plugins or managers:
|
||||
" it works out of the box with the stock vim, and the AI edit feature uses only
|
||||
" curl and the PRAVDA_* gateway env that every instance is launched with.
|
||||
|
||||
set nocompatible
|
||||
filetype plugin indent on
|
||||
syntax on
|
||||
|
||||
set encoding=utf-8
|
||||
set fileencoding=utf-8
|
||||
set termencoding=utf-8
|
||||
set mouse=a
|
||||
set backspace=indent,eol,start
|
||||
set autoindent
|
||||
set smartindent
|
||||
set tabstop=4
|
||||
set shiftwidth=4
|
||||
set expandtab
|
||||
set number
|
||||
set showmatch
|
||||
set showtabline=2
|
||||
set laststatus=2
|
||||
set hidden
|
||||
set incsearch
|
||||
set hlsearch
|
||||
set wildmenu
|
||||
set ttimeoutlen=50
|
||||
|
||||
if has('clipboard')
|
||||
set clipboard=unnamedplus
|
||||
endif
|
||||
|
||||
if !isdirectory(expand('~/.vim/undo'))
|
||||
call mkdir(expand('~/.vim/undo'), 'p')
|
||||
endif
|
||||
set undofile
|
||||
set undodir=~/.vim/undo
|
||||
|
||||
set statusline=%f\ %h%m%r\ %=\ [%{&filetype}]\ [%l,%c]\ %p%%
|
||||
highlight StatusLine cterm=bold ctermfg=15 ctermbg=24
|
||||
highlight StatusLineNC cterm=none ctermfg=250 ctermbg=236
|
||||
highlight ErrorMsg cterm=bold ctermfg=red ctermbg=none
|
||||
|
||||
let mapleader = ","
|
||||
|
||||
inoremap <C-n> <ESC>:tabnext<CR>
|
||||
inoremap <C-p> <ESC>:tabprevious<CR>
|
||||
nnoremap <C-n> :tabnext<CR>
|
||||
nnoremap <C-p> :tabprevious<CR>
|
||||
nnoremap <Tab> :tabnext<CR>
|
||||
nnoremap <C-Tab> :tabprevious<CR>
|
||||
nnoremap <C-e> :tabnew<Space>
|
||||
inoremap <C-e> <ESC>:tabnew<Space>
|
||||
|
||||
if has('autocmd')
|
||||
autocmd BufReadPost * if line("'\"") > 0 && line("'\"") <= line("$") | exe "normal! g'\"" | endif
|
||||
endif
|
||||
|
||||
function! s:GetVisualSelection() abort
|
||||
let [l:line_start, l:col_start] = [line("'<"), col("'<")]
|
||||
let [l:line_end, l:col_end] = [line("'>"), col("'>")]
|
||||
let l:lines = getline(l:line_start, l:line_end)
|
||||
if empty(l:lines)
|
||||
return ''
|
||||
endif
|
||||
let l:lines[-1] = l:lines[-1][: l:col_end - (l:line_start == l:line_end ? 1 : 2)]
|
||||
let l:lines[0] = l:lines[0][l:col_start - 1 :]
|
||||
return join(l:lines, "\n")
|
||||
endfunction
|
||||
|
||||
function! s:GatewayUrl() abort
|
||||
let l:base = substitute($PRAVDA_OPENAI_URL, '/\+$', '', '')
|
||||
if empty(l:base)
|
||||
return 'https://openai.app.molodetz.nl/v1/chat/completions'
|
||||
elseif l:base =~# '/chat/completions$'
|
||||
return l:base
|
||||
endif
|
||||
return l:base . '/chat/completions'
|
||||
endfunction
|
||||
|
||||
function! AiEditSelection() abort
|
||||
let l:instruction = input('AI instruction: ')
|
||||
if empty(l:instruction)
|
||||
echo 'Cancelled.'
|
||||
return
|
||||
endif
|
||||
let l:orig = s:GetVisualSelection()
|
||||
if empty(l:orig)
|
||||
echo 'No selection.'
|
||||
return
|
||||
endif
|
||||
let l:prompt = l:instruction . "\n\nHere is the text:\n" . l:orig
|
||||
\ . "\n\nOutput only the transformed text. No explanations, markdown, or code blocks."
|
||||
let l:api_key = !empty($PRAVDA_API_KEY) ? $PRAVDA_API_KEY : $DEEPSEEK_API_KEY
|
||||
let l:json = '{"model":"deepseek-chat","messages":[{"role":"user","content":' . json_encode(l:prompt) . '}]}'
|
||||
let l:cmd = 'curl -sS -X POST ' . shellescape(s:GatewayUrl())
|
||||
\ . ' -H ' . shellescape('Authorization: Bearer ' . l:api_key)
|
||||
\ . ' -H ' . shellescape('Content-Type: application/json')
|
||||
\ . ' -d ' . shellescape(l:json)
|
||||
let l:reply = system(l:cmd)
|
||||
if v:shell_error
|
||||
echohl ErrorMsg | echom 'AI request failed' | echohl None
|
||||
return
|
||||
endif
|
||||
let l:text = matchstr(l:reply, '"content":\s*"\zs\(.\{-}\)\ze"\s*[,}]')
|
||||
if empty(l:text)
|
||||
echohl ErrorMsg | echom 'No content in AI response' | echohl None
|
||||
return
|
||||
endif
|
||||
let l:text = substitute(l:text, '\\n', "\n", 'g')
|
||||
let l:text = substitute(l:text, '\\"', '"', 'g')
|
||||
let l:text = substitute(l:text, '\\t', "\t", 'g')
|
||||
normal! gv
|
||||
normal! c
|
||||
call feedkeys(l:text, 'n')
|
||||
endfunction
|
||||
|
||||
xnoremap <silent> <Leader>a :<C-u>call AiEditSelection()<CR>
|
||||
2940
devplacepy/services/containers/files/bot.py.bak
Normal file
2940
devplacepy/services/containers/files/bot.py.bak
Normal file
File diff suppressed because it is too large
Load Diff
2337
devplacepy/services/containers/files/d.py.bak
Executable file
2337
devplacepy/services/containers/files/d.py.bak
Executable file
File diff suppressed because it is too large
Load Diff
2669
devplacepy/services/containers/files/pagent.bak
Executable file
2669
devplacepy/services/containers/files/pagent.bak
Executable file
File diff suppressed because it is too large
Load Diff
200
devplacepy/services/devii/actions/container_actions.py.bak
Normal file
200
devplacepy/services/devii/actions/container_actions.py.bak
Normal file
@ -0,0 +1,200 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
|
||||
def arg(
|
||||
name: str, description: str, required: bool = False, kind: str = "string"
|
||||
) -> Param:
|
||||
return Param(
|
||||
name=name,
|
||||
location="body",
|
||||
description=description,
|
||||
required=required,
|
||||
type=kind,
|
||||
)
|
||||
|
||||
|
||||
SLUG = arg(
|
||||
"project_slug",
|
||||
"Project slug or uid that owns the container resources.",
|
||||
required=True,
|
||||
)
|
||||
|
||||
CONTAINER_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="container_list_instances",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
summary="List a project's container instances and their status",
|
||||
params=(SLUG,),
|
||||
),
|
||||
Action(
|
||||
name="container_create_instance",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Create and start a container instance (runs the shared ppy image with the project files mounted at /app)",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("name", "Instance name.", required=True),
|
||||
arg(
|
||||
"boot_command", "Optional command to run on boot, e.g. 'python app.py'."
|
||||
),
|
||||
arg(
|
||||
"boot_language",
|
||||
"Optional boot source language: 'none', 'python', or 'bash'. When set with boot_script, the script is materialized into /app and run on launch (takes precedence over boot_command).",
|
||||
),
|
||||
arg(
|
||||
"boot_script",
|
||||
"Optional boot source code (the body of the python or bash script) run on launch when boot_language is python or bash.",
|
||||
),
|
||||
arg(
|
||||
"run_as_uid",
|
||||
"Optional DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user, which is always pravda (uid 1000).",
|
||||
),
|
||||
arg(
|
||||
"start_on_boot",
|
||||
"Force this instance to running whenever the container service starts ('true' or 'false', default false).",
|
||||
),
|
||||
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
|
||||
arg("env", "Optional env vars as KEY=VALUE lines."),
|
||||
arg(
|
||||
"ports",
|
||||
"Port maps per line or comma separated. Use a bare container port (e.g. '8899') to auto-assign a unique host port above 20000, or 'host:container' to pin one.",
|
||||
),
|
||||
arg("cpu_limit", "Optional CPU limit, e.g. 1 or 1.5."),
|
||||
arg("mem_limit", "Optional memory limit, e.g. 512m or 1g."),
|
||||
arg("autostart", "Start immediately ('true' or 'false', default true)."),
|
||||
arg(
|
||||
"ingress_slug",
|
||||
"Optional public ingress slug; the service is then reachable at /p/<slug>.",
|
||||
),
|
||||
arg(
|
||||
"ingress_port",
|
||||
"Container port to publish at /p/<slug> (must be one of the mapped ports).",
|
||||
kind="integer",
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_instance_action",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Control an instance: start, stop, restart, pause, resume, delete, or sync",
|
||||
description="sync imports the container /app workspace back into the project files.",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg(
|
||||
"action",
|
||||
"start, stop, restart, pause, resume, delete, or sync.",
|
||||
required=True,
|
||||
),
|
||||
arg(
|
||||
"confirm",
|
||||
"Required only for action=delete: set true ONLY after the user has explicitly "
|
||||
"confirmed destroying the instance. Leave unset otherwise; the delete is refused "
|
||||
"until you pass confirm=true.",
|
||||
kind="boolean",
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_configure_instance",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg(
|
||||
"run_as_uid",
|
||||
"DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID); pass empty to clear. Does NOT change the container OS user (always pravda, uid 1000).",
|
||||
),
|
||||
arg("boot_language", "Boot source language: 'none', 'python', or 'bash'."),
|
||||
arg("boot_script", "Boot source code body run on launch."),
|
||||
arg("boot_command", "Fallback boot command used when no boot_script is set."),
|
||||
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
|
||||
arg(
|
||||
"start_on_boot",
|
||||
"Force running on container-service start ('true' or 'false').",
|
||||
),
|
||||
arg("cpu_limit", "CPU limit, e.g. 1 or 1.5."),
|
||||
arg("mem_limit", "Memory limit, e.g. 512m or 1g."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_logs",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
summary="Read the recent logs of a running instance",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg("tail", "Number of log lines (default 200).", kind="integer"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_exec",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Run a one-shot command inside a running instance and return its output. The command runs in /app (the project workspace) by default, so never prefix it with 'cd /app'",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg(
|
||||
"command",
|
||||
"Command to run, e.g. 'git clone ... && ls'. Runs in /app already; do not prepend 'cd /app'.",
|
||||
required=True,
|
||||
),
|
||||
arg(
|
||||
"confirm",
|
||||
"Required only when the command is destructive (rm, dd, truncate, drop, etc.): set "
|
||||
"true ONLY after the user has explicitly confirmed. Leave unset otherwise; a "
|
||||
"destructive command is refused until you pass confirm=true.",
|
||||
kind="boolean",
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_stats",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
summary="Get aggregated resource and runtime statistics for an instance",
|
||||
params=(SLUG, arg("instance", "Instance name, slug, or uid.", required=True)),
|
||||
),
|
||||
Action(
|
||||
name="container_schedule",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Schedule a start or stop of an instance (cron, interval, or one-time)",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg("action", "start or stop.", required=True),
|
||||
arg("kind", "once, interval, or cron.", required=True),
|
||||
arg("cron", "Cron expression for kind=cron, e.g. '0 2 * * *'."),
|
||||
arg("run_at", "ISO time for kind=once, e.g. 2026-06-15T02:00:00."),
|
||||
arg("every_seconds", "Interval seconds for kind=interval.", kind="integer"),
|
||||
),
|
||||
),
|
||||
)
|
||||
1
devplacepy/services/jobs/isslop/__init__.py
Normal file
1
devplacepy/services/jobs/isslop/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
1
devplacepy/services/jobs/isslop/acquisition/__init__.py
Normal file
1
devplacepy/services/jobs/isslop/acquisition/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
313
devplacepy/services/jobs/isslop/acquisition/browser.py
Normal file
313
devplacepy/services/jobs/isslop/acquisition/browser.py
Normal file
@ -0,0 +1,313 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from types import TracebackType
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from devplacepy.services.jobs.isslop.acquisition.domcapture import (
|
||||
DOM_EXTRACT_SCRIPT,
|
||||
DOM_INIT_SCRIPT,
|
||||
DomSnapshot,
|
||||
)
|
||||
from devplacepy.services.jobs.isslop.config import (
|
||||
CONSOLE_SAMPLE_CAP,
|
||||
DOM_CAPTURE_TIMEOUT_MS,
|
||||
HEADER_VALUE_CAP,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BROWSER_USER_AGENT: str = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
)
|
||||
BROWSER_VIEWPORT: dict[str, int] = {"width": 1440, "height": 900}
|
||||
BROWSER_LOCALE: str = "en-US"
|
||||
BROWSER_TIMEZONE: str = "America/New_York"
|
||||
|
||||
LAUNCH_ARGS: list[str] = [
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--disable-gpu",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-extensions",
|
||||
]
|
||||
|
||||
NAVIGATION_TIMEOUT_MS: int = 30000
|
||||
RENDER_SETTLE_MS: int = 1200
|
||||
FETCH_TIMEOUT_MS: int = 25000
|
||||
BROWSER_CLOSE_TIMEOUT_S: float = 15.0
|
||||
LAUNCH_TIMEOUT_S: float = 60.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderResult:
|
||||
url: str
|
||||
final_url: str
|
||||
status: int
|
||||
html: str
|
||||
content_type: str
|
||||
ok: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FetchResult:
|
||||
url: str
|
||||
status: int
|
||||
body: bytes
|
||||
content_type: str
|
||||
ok: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def browser_available() -> bool:
|
||||
try:
|
||||
import playwright.async_api # noqa: F401
|
||||
import playwright_stealth # noqa: F401
|
||||
except ImportError as error:
|
||||
logger.info("Stealth browser unavailable, will use HTTP client: %s", error)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class StealthBrowser:
|
||||
def __init__(self, tab_concurrency: int = 4) -> None:
|
||||
self._tab_concurrency = max(1, tab_concurrency)
|
||||
self._stealth_manager = None
|
||||
self._playwright = None
|
||||
self._browser = None
|
||||
self._context = None
|
||||
self._tab_semaphore = asyncio.Semaphore(self._tab_concurrency)
|
||||
|
||||
async def __aenter__(self) -> "StealthBrowser":
|
||||
await self._start()
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: Optional[type[BaseException]],
|
||||
exc: Optional[BaseException],
|
||||
traceback: Optional[TracebackType],
|
||||
) -> None:
|
||||
await self.aclose()
|
||||
|
||||
async def _start(self) -> None:
|
||||
from playwright.async_api import async_playwright
|
||||
from playwright_stealth import Stealth
|
||||
|
||||
stealth = Stealth()
|
||||
self._stealth_manager = stealth.use_async(async_playwright())
|
||||
self._playwright = await asyncio.wait_for(self._stealth_manager.__aenter__(), timeout=LAUNCH_TIMEOUT_S)
|
||||
self._browser = await self._launch_browser()
|
||||
self._context = await self._browser.new_context(
|
||||
user_agent=BROWSER_USER_AGENT,
|
||||
viewport=BROWSER_VIEWPORT,
|
||||
locale=BROWSER_LOCALE,
|
||||
timezone_id=BROWSER_TIMEZONE,
|
||||
ignore_https_errors=True,
|
||||
java_script_enabled=True,
|
||||
)
|
||||
self._context.set_default_navigation_timeout(NAVIGATION_TIMEOUT_MS)
|
||||
self._context.set_default_timeout(NAVIGATION_TIMEOUT_MS)
|
||||
logger.info("Stealth browser ready (tab concurrency %d)", self._tab_concurrency)
|
||||
|
||||
async def _launch_browser(self):
|
||||
errors: list[str] = []
|
||||
for channel in ("chrome", None):
|
||||
try:
|
||||
kwargs: dict[str, object] = {"headless": True, "args": LAUNCH_ARGS}
|
||||
if channel:
|
||||
kwargs["channel"] = channel
|
||||
browser = await asyncio.wait_for(
|
||||
self._playwright.chromium.launch(**kwargs), timeout=LAUNCH_TIMEOUT_S
|
||||
)
|
||||
logger.info("Chromium launched (channel=%s)", channel or "bundled")
|
||||
return browser
|
||||
except Exception as error: # noqa: BLE001 - launch failures are heterogeneous, we retry the next channel
|
||||
errors.append(f"{channel or 'bundled'}: {error}")
|
||||
logger.warning("Browser launch failed on channel %s: %s", channel or "bundled", error)
|
||||
raise RuntimeError(f"Could not launch any Chromium build: {'; '.join(errors)}")
|
||||
|
||||
async def _goto_and_settle(self, page: object, url: str) -> object:
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeout
|
||||
|
||||
response = await page.goto(url, wait_until="domcontentloaded", timeout=NAVIGATION_TIMEOUT_MS)
|
||||
try:
|
||||
await page.wait_for_load_state("networkidle", timeout=RENDER_SETTLE_MS * 3)
|
||||
except PlaywrightTimeout:
|
||||
await page.wait_for_timeout(RENDER_SETTLE_MS)
|
||||
return response
|
||||
|
||||
async def _build_render_result(self, page: object, response: object, url: str) -> RenderResult:
|
||||
html = await page.content()
|
||||
status = response.status if response else 0
|
||||
content_type = ""
|
||||
if response is not None:
|
||||
headers = await response.all_headers()
|
||||
content_type = headers.get("content-type", "")
|
||||
final_url = page.url
|
||||
return RenderResult(
|
||||
url=url,
|
||||
final_url=final_url,
|
||||
status=status,
|
||||
html=html,
|
||||
content_type=content_type or "text/html",
|
||||
ok=bool(html) and status < 400,
|
||||
)
|
||||
|
||||
async def render(self, url: str) -> RenderResult:
|
||||
from playwright.async_api import Error as PlaywrightError
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeout
|
||||
|
||||
async with self._tab_semaphore:
|
||||
page = None
|
||||
try:
|
||||
page = await self._context.new_page()
|
||||
response = await self._goto_and_settle(page, url)
|
||||
return await self._build_render_result(page, response, url)
|
||||
except (PlaywrightTimeout, PlaywrightError) as error:
|
||||
logger.warning("Render failed for %s: %s", url, error)
|
||||
return RenderResult(url, url, 0, "", "", False, str(error))
|
||||
finally:
|
||||
if page is not None:
|
||||
try:
|
||||
await page.close()
|
||||
except Exception as error: # noqa: BLE001 - a failed tab close must never abort the crawl
|
||||
logger.debug("Tab close error for %s: %s", url, error)
|
||||
|
||||
async def _evaluate_dom_and_screenshot(self, page: object) -> tuple[dict, Optional[bytes]]:
|
||||
from playwright.async_api import Error as PlaywrightError
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeout
|
||||
|
||||
dom: dict = {}
|
||||
try:
|
||||
dom = await page.evaluate(DOM_EXTRACT_SCRIPT)
|
||||
except (PlaywrightTimeout, PlaywrightError) as error:
|
||||
logger.warning("DOM evaluate failed: %s", error)
|
||||
dom = {}
|
||||
screenshot_bytes: Optional[bytes] = None
|
||||
try:
|
||||
screenshot_bytes = await page.screenshot(full_page=False)
|
||||
except (PlaywrightTimeout, PlaywrightError) as error:
|
||||
logger.warning("Screenshot capture failed: %s", error)
|
||||
screenshot_bytes = None
|
||||
return dom if isinstance(dom, dict) else {}, screenshot_bytes
|
||||
|
||||
async def capture(self, url: str) -> DomSnapshot:
|
||||
from playwright.async_api import Error as PlaywrightError
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeout
|
||||
|
||||
async with self._tab_semaphore:
|
||||
page = None
|
||||
try:
|
||||
page = await self._context.new_page()
|
||||
console_warnings: list[str] = []
|
||||
console_errors: list[str] = []
|
||||
|
||||
def _on_console(message: object) -> None:
|
||||
try:
|
||||
kind = message.type
|
||||
text = message.text[:HEADER_VALUE_CAP]
|
||||
except Exception as error:
|
||||
logger.debug("Console handler error: %s", error)
|
||||
return
|
||||
if kind == "warning" and len(console_warnings) < CONSOLE_SAMPLE_CAP:
|
||||
console_warnings.append(text)
|
||||
elif kind == "error" and len(console_errors) < CONSOLE_SAMPLE_CAP:
|
||||
console_errors.append(text)
|
||||
|
||||
page.on("console", _on_console)
|
||||
|
||||
response_headers: dict[str, str] = {}
|
||||
resource_hosts: list[str] = []
|
||||
seen_hosts: set[str] = set()
|
||||
captured_main = False
|
||||
|
||||
def _on_response(response: object) -> None:
|
||||
nonlocal captured_main
|
||||
try:
|
||||
hostname = urlparse(response.url).hostname
|
||||
if hostname and hostname not in seen_hosts:
|
||||
seen_hosts.add(hostname)
|
||||
resource_hosts.append(hostname)
|
||||
if not captured_main and response.frame == page.main_frame:
|
||||
captured_main = True
|
||||
for key, value in response.headers.items():
|
||||
response_headers[key] = str(value)[:HEADER_VALUE_CAP]
|
||||
except Exception as error:
|
||||
logger.debug("Response handler error: %s", error)
|
||||
|
||||
page.on("response", _on_response)
|
||||
await page.add_init_script(DOM_INIT_SCRIPT)
|
||||
response = await self._goto_and_settle(page, url)
|
||||
render = await self._build_render_result(page, response, url)
|
||||
dom: dict = {}
|
||||
screenshot_bytes: Optional[bytes] = None
|
||||
try:
|
||||
dom, screenshot_bytes = await asyncio.wait_for(
|
||||
self._evaluate_dom_and_screenshot(page),
|
||||
timeout=DOM_CAPTURE_TIMEOUT_MS / 1000,
|
||||
)
|
||||
except asyncio.TimeoutError as error:
|
||||
logger.warning("DOM capture timed out for %s: %s", url, error)
|
||||
return DomSnapshot(
|
||||
render=render,
|
||||
dom=dom,
|
||||
console_warnings=console_warnings,
|
||||
console_errors=console_errors,
|
||||
response_headers=response_headers,
|
||||
resource_hosts=resource_hosts,
|
||||
screenshot_bytes=screenshot_bytes,
|
||||
)
|
||||
except (PlaywrightTimeout, PlaywrightError) as error:
|
||||
logger.warning("Capture failed for %s: %s", url, error)
|
||||
return DomSnapshot(render=RenderResult(url, url, 0, "", "", False, str(error)))
|
||||
finally:
|
||||
if page is not None:
|
||||
try:
|
||||
await page.close()
|
||||
except Exception as error:
|
||||
logger.debug("Tab close error for %s: %s", url, error)
|
||||
|
||||
async def fetch(self, url: str, max_bytes: int) -> FetchResult:
|
||||
from playwright.async_api import Error as PlaywrightError
|
||||
|
||||
try:
|
||||
response = await self._context.request.get(url, timeout=FETCH_TIMEOUT_MS)
|
||||
body = await response.body()
|
||||
if len(body) > max_bytes:
|
||||
body = body[:max_bytes]
|
||||
content_type = response.headers.get("content-type", "")
|
||||
return FetchResult(url, response.status, body, content_type, response.status < 400)
|
||||
except (PlaywrightError, asyncio.TimeoutError) as error:
|
||||
logger.warning("Fetch failed for %s: %s", url, error)
|
||||
return FetchResult(url, 0, b"", "", False, str(error))
|
||||
|
||||
async def aclose(self) -> None:
|
||||
for closer, label in (
|
||||
(self._context, "context"),
|
||||
(self._browser, "browser"),
|
||||
):
|
||||
if closer is not None:
|
||||
try:
|
||||
await asyncio.wait_for(closer.close(), timeout=BROWSER_CLOSE_TIMEOUT_S)
|
||||
except Exception as error: # noqa: BLE001 - cleanup must be exhaustive
|
||||
logger.warning("Error closing %s: %s", label, error)
|
||||
self._context = None
|
||||
self._browser = None
|
||||
if self._stealth_manager is not None:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._stealth_manager.__aexit__(None, None, None), timeout=BROWSER_CLOSE_TIMEOUT_S
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - playwright teardown must not leak
|
||||
logger.warning("Error stopping playwright: %s", error)
|
||||
self._stealth_manager = None
|
||||
self._playwright = None
|
||||
logger.info("Stealth browser closed")
|
||||
212
devplacepy/services/jobs/isslop/acquisition/domcapture.py
Normal file
212
devplacepy/services/jobs/isslop/acquisition/domcapture.py
Normal file
@ -0,0 +1,212 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from devplacepy.services.jobs.isslop.acquisition.browser import RenderResult
|
||||
|
||||
DOM_INIT_SCRIPT = """
|
||||
window.__isslop = {};
|
||||
"""
|
||||
|
||||
DOM_EXTRACT_SCRIPT = r"""
|
||||
() => {
|
||||
const safe = (fn, fallback) => { try { return fn(); } catch (e) { return fallback; } };
|
||||
const metaByName = (name) => safe(() => {
|
||||
const el = document.querySelector(`meta[name="${name}"]`);
|
||||
return el ? (el.getAttribute('content') || '') : '';
|
||||
}, '');
|
||||
const metaByProperty = (name) => safe(() => {
|
||||
const el = document.querySelector(`meta[property="${name}"]`);
|
||||
return el ? (el.getAttribute('content') || '') : '';
|
||||
}, '');
|
||||
const linkHref = (rel) => safe(() => {
|
||||
const el = document.querySelector(`link[rel="${rel}"]`);
|
||||
return el ? (el.getAttribute('href') || '') : '';
|
||||
}, '');
|
||||
const meta = {
|
||||
title: safe(() => (document.title || '').trim(), ''),
|
||||
generator: metaByName('generator'),
|
||||
description: metaByName('description'),
|
||||
ogImage: metaByProperty('og:image'),
|
||||
canonical: linkHref('canonical'),
|
||||
htmlLang: safe(() => document.documentElement.getAttribute('lang') || '', ''),
|
||||
favicon: safe(() => linkHref('icon') || linkHref('shortcut icon'), ''),
|
||||
};
|
||||
const badgeSelectors = [
|
||||
'#lovable-badge',
|
||||
'.replit-badge',
|
||||
'#base44-badge',
|
||||
'#__framer-badge-container',
|
||||
'[data-radix-root]',
|
||||
'iframe[src*="claudeusercontent.com"]',
|
||||
];
|
||||
const badgeHits = {};
|
||||
badgeSelectors.forEach((sel) => {
|
||||
badgeHits[sel] = safe(() => !!document.querySelector(sel), false);
|
||||
});
|
||||
const headings = [];
|
||||
safe(() => {
|
||||
document.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach((h) => {
|
||||
if (headings.length >= 60) return;
|
||||
headings.push({
|
||||
level: parseInt(h.tagName.substring(1), 10),
|
||||
text: (h.textContent || '').trim().slice(0, 160),
|
||||
});
|
||||
});
|
||||
}, null);
|
||||
const landmarks = {
|
||||
main: safe(() => document.querySelectorAll('main').length, 0),
|
||||
nav: safe(() => document.querySelectorAll('nav').length, 0),
|
||||
header: safe(() => document.querySelectorAll('header').length, 0),
|
||||
footer: safe(() => document.querySelectorAll('footer').length, 0),
|
||||
article: safe(() => document.querySelectorAll('article').length, 0),
|
||||
};
|
||||
const images = [];
|
||||
safe(() => {
|
||||
document.querySelectorAll('img').forEach((img) => {
|
||||
if (images.length >= 100) return;
|
||||
images.push({
|
||||
src: img.getAttribute('src') || '',
|
||||
alt: img.getAttribute('alt') || '',
|
||||
});
|
||||
});
|
||||
}, null);
|
||||
const classCounts = {};
|
||||
safe(() => {
|
||||
document.querySelectorAll('[class]').forEach((el) => {
|
||||
const raw = el.getAttribute('class') || '';
|
||||
raw.split(/\s+/).filter(Boolean).forEach((token) => {
|
||||
classCounts[token] = (classCounts[token] || 0) + 1;
|
||||
});
|
||||
});
|
||||
}, null);
|
||||
const classNames = {};
|
||||
Object.keys(classCounts)
|
||||
.sort((a, b) => classCounts[b] - classCounts[a])
|
||||
.slice(0, 40)
|
||||
.forEach((token) => { classNames[token] = classCounts[token]; });
|
||||
const colorSample = (tag, el) => safe(() => {
|
||||
const style = getComputedStyle(el);
|
||||
return {
|
||||
tag: tag,
|
||||
backgroundImage: style.backgroundImage || '',
|
||||
backgroundColor: style.backgroundColor || '',
|
||||
color: style.color || '',
|
||||
boxShadow: style.boxShadow || '',
|
||||
};
|
||||
}, null);
|
||||
const colors = [];
|
||||
safe(() => {
|
||||
if (document.body) {
|
||||
const sample = colorSample('body', document.body);
|
||||
if (sample) colors.push(sample);
|
||||
}
|
||||
}, null);
|
||||
safe(() => {
|
||||
Array.from(document.querySelectorAll('h1,h2')).slice(0, 6).forEach((el) => {
|
||||
const sample = colorSample(el.tagName.toLowerCase(), el);
|
||||
if (sample) colors.push(sample);
|
||||
});
|
||||
}, null);
|
||||
safe(() => {
|
||||
Array.from(document.querySelectorAll('button, a.btn, [class*="btn"]')).slice(0, 10).forEach((el) => {
|
||||
const sample = colorSample(el.tagName.toLowerCase(), el);
|
||||
if (sample) colors.push(sample);
|
||||
});
|
||||
}, null);
|
||||
safe(() => {
|
||||
Array.from(document.querySelectorAll('nav, header')).slice(0, 4).forEach((el) => {
|
||||
const sample = colorSample(el.tagName.toLowerCase(), el);
|
||||
if (sample) colors.push(sample);
|
||||
});
|
||||
}, null);
|
||||
safe(() => {
|
||||
Array.from(document.querySelectorAll('[class*="hero"]')).slice(0, 4).forEach((el) => {
|
||||
const sample = colorSample(el.tagName.toLowerCase(), el);
|
||||
if (sample) colors.push(sample);
|
||||
});
|
||||
}, null);
|
||||
const fontSample = (tag, el) => safe(() => {
|
||||
const style = getComputedStyle(el);
|
||||
return {
|
||||
tag: tag,
|
||||
fontFamily: style.fontFamily || '',
|
||||
fontWeight: style.fontWeight || '',
|
||||
letterSpacing: style.letterSpacing || '',
|
||||
fontSize: style.fontSize || '',
|
||||
lineHeight: style.lineHeight || '',
|
||||
};
|
||||
}, null);
|
||||
const fonts = [];
|
||||
safe(() => {
|
||||
if (document.body) {
|
||||
const sample = fontSample('body', document.body);
|
||||
if (sample) fonts.push(sample);
|
||||
}
|
||||
}, null);
|
||||
safe(() => {
|
||||
Array.from(document.querySelectorAll('h1,h2,h3')).slice(0, 10).forEach((el) => {
|
||||
const sample = fontSample(el.tagName.toLowerCase(), el);
|
||||
if (sample) fonts.push(sample);
|
||||
});
|
||||
}, null);
|
||||
safe(() => {
|
||||
Array.from(document.querySelectorAll('p, button')).slice(0, 8).forEach((el) => {
|
||||
if (fonts.length >= 20) return;
|
||||
const sample = fontSample(el.tagName.toLowerCase(), el);
|
||||
if (sample) fonts.push(sample);
|
||||
});
|
||||
}, null);
|
||||
const scripts = [];
|
||||
safe(() => {
|
||||
document.querySelectorAll('script[src]').forEach((s) => {
|
||||
if (scripts.length >= 60) return;
|
||||
scripts.push(s.getAttribute('src') || '');
|
||||
});
|
||||
}, null);
|
||||
const links = [];
|
||||
safe(() => {
|
||||
document.querySelectorAll('link[href]').forEach((l) => {
|
||||
if (links.length >= 60) return;
|
||||
links.push(l.getAttribute('href') || '');
|
||||
});
|
||||
}, null);
|
||||
const wordCount = safe(() => {
|
||||
const text = document.body ? (document.body.innerText || '') : '';
|
||||
return text.split(/\s+/).filter(Boolean).length;
|
||||
}, 0);
|
||||
const deadAnchorCount = safe(() => document.querySelectorAll('a[href="#"]').length, 0);
|
||||
const jsonldCount = safe(() => document.querySelectorAll('script[type="application/ld+json"]').length, 0);
|
||||
const robotsMeta = metaByName('robots');
|
||||
return {
|
||||
meta: meta,
|
||||
badgeHits: badgeHits,
|
||||
headings: headings.slice(0, 60),
|
||||
landmarks: landmarks,
|
||||
images: images.slice(0, 100),
|
||||
classNames: classNames,
|
||||
colors: colors.slice(0, 30),
|
||||
fonts: fonts.slice(0, 20),
|
||||
jsonld: jsonldCount,
|
||||
scripts: scripts.slice(0, 60),
|
||||
links: links.slice(0, 60),
|
||||
wordCount: wordCount,
|
||||
deadAnchorCount: deadAnchorCount,
|
||||
robotsMeta: robotsMeta,
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DomSnapshot:
|
||||
render: RenderResult
|
||||
dom: dict = field(default_factory=dict)
|
||||
console_warnings: list[str] = field(default_factory=list)
|
||||
console_errors: list[str] = field(default_factory=list)
|
||||
response_headers: dict[str, str] = field(default_factory=dict)
|
||||
resource_hosts: list[str] = field(default_factory=list)
|
||||
screenshot_bytes: bytes | None = None
|
||||
163
devplacepy/services/jobs/isslop/acquisition/git.py
Normal file
163
devplacepy/services/jobs/isslop/acquisition/git.py
Normal file
@ -0,0 +1,163 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy.stealth import stealth_async_client
|
||||
|
||||
from devplacepy.services.jobs.isslop.acquisition.workspace import directory_size_bytes
|
||||
from devplacepy.services.jobs.isslop.config import (
|
||||
GIT_CLONE_TIMEOUT_SECONDS,
|
||||
GIT_SIZE_LIMIT_BYTES,
|
||||
GIT_SIZE_POLL_SECONDS,
|
||||
WEBSITE_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RepositoryTooLargeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CloneFailedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SizePreflight:
|
||||
known: bool
|
||||
size_bytes: int
|
||||
origin: str
|
||||
|
||||
|
||||
def _owner_repo(url: str) -> Optional[tuple[str, str, str]]:
|
||||
match = re.match(r"^git@([\w.-]+):(.+)$", url)
|
||||
if match:
|
||||
host, path = match.group(1), match.group(2)
|
||||
else:
|
||||
parsed = urlparse(url)
|
||||
host, path = parsed.hostname or "", parsed.path
|
||||
parts = [part for part in path.strip("/").removesuffix(".git").split("/") if part]
|
||||
if len(parts) < 2 or not host:
|
||||
return None
|
||||
return host, parts[0], parts[1]
|
||||
|
||||
|
||||
async def preflight_size(url: str) -> SizePreflight:
|
||||
located = _owner_repo(url)
|
||||
if located is None:
|
||||
return SizePreflight(known=False, size_bytes=0, origin="unparseable")
|
||||
host, owner, repo = located
|
||||
candidates: list[tuple[str, str]] = []
|
||||
if host == "github.com":
|
||||
candidates.append((f"https://api.github.com/repos/{owner}/{repo}", "github"))
|
||||
else:
|
||||
candidates.append((f"https://{host}/api/v1/repos/{owner}/{repo}", "gitea"))
|
||||
async with stealth_async_client(timeout=WEBSITE_REQUEST_TIMEOUT_SECONDS, follow_redirects=True) as client:
|
||||
for api_url, origin in candidates:
|
||||
try:
|
||||
response = await client.get(api_url, headers={"accept": "application/json"})
|
||||
except httpx.HTTPError as error:
|
||||
logger.info("Size preflight unavailable via %s: %s", origin, error)
|
||||
continue
|
||||
if response.status_code != 200:
|
||||
logger.info("Size preflight %s returned HTTP %d", origin, response.status_code)
|
||||
continue
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
continue
|
||||
size_kb = body.get("size")
|
||||
if isinstance(size_kb, (int, float)) and size_kb > 0:
|
||||
size_bytes = int(size_kb) * 1024
|
||||
logger.info("Preflight size via %s: %d bytes", origin, size_bytes)
|
||||
return SizePreflight(known=True, size_bytes=size_bytes, origin=origin)
|
||||
return SizePreflight(known=False, size_bytes=0, origin="unknown")
|
||||
|
||||
|
||||
async def clone_repository(url: str, workspace: Path, size_limit: int = GIT_SIZE_LIMIT_BYTES) -> AsyncIterator[str]:
|
||||
preflight = await preflight_size(url)
|
||||
if preflight.known:
|
||||
yield f"Repository size reported by {preflight.origin}: {preflight.size_bytes / (1024 * 1024):.1f} MB"
|
||||
if preflight.size_bytes > size_limit:
|
||||
raise RepositoryTooLargeError(
|
||||
f"Repository is {preflight.size_bytes / (1024 ** 3):.2f} GB which exceeds the 3 GB limit"
|
||||
)
|
||||
else:
|
||||
yield "Repository size not determinable up front, monitoring during clone"
|
||||
|
||||
argv = [
|
||||
"git",
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"--single-branch",
|
||||
"--no-tags",
|
||||
url,
|
||||
str(workspace),
|
||||
]
|
||||
yield f"Cloning with depth 1: {url}"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
env={"GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "true", "PATH": "/usr/bin:/bin:/usr/local/bin"},
|
||||
)
|
||||
|
||||
aborted_reason: list[str] = []
|
||||
|
||||
async def monitor() -> None:
|
||||
while proc.returncode is None:
|
||||
await asyncio.sleep(GIT_SIZE_POLL_SECONDS)
|
||||
if not workspace.exists():
|
||||
continue
|
||||
size = await asyncio.to_thread(directory_size_bytes, workspace)
|
||||
logger.debug("Clone size check: %d bytes", size)
|
||||
if size > size_limit:
|
||||
aborted_reason.append(f"Clone exceeded 3 GB limit at {size / (1024 ** 3):.2f} GB, cancelled")
|
||||
proc.kill()
|
||||
return
|
||||
|
||||
monitor_task = asyncio.create_task(monitor())
|
||||
output_lines: list[str] = []
|
||||
try:
|
||||
assert proc.stdout is not None
|
||||
while True:
|
||||
try:
|
||||
line = await asyncio.wait_for(proc.stdout.readline(), timeout=GIT_CLONE_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
raise CloneFailedError("Clone timed out")
|
||||
if not line:
|
||||
break
|
||||
text = line.decode("utf-8", errors="replace").strip()
|
||||
if text:
|
||||
output_lines.append(text)
|
||||
yield f"git: {text}"
|
||||
await proc.wait()
|
||||
finally:
|
||||
monitor_task.cancel()
|
||||
try:
|
||||
await monitor_task
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Clone size monitor stopped")
|
||||
|
||||
if aborted_reason:
|
||||
raise RepositoryTooLargeError(aborted_reason[0])
|
||||
if proc.returncode != 0:
|
||||
tail = " | ".join(output_lines[-4:])
|
||||
raise CloneFailedError(f"git clone failed with code {proc.returncode}: {tail}")
|
||||
final_size = await asyncio.to_thread(directory_size_bytes, workspace)
|
||||
if final_size > size_limit:
|
||||
raise RepositoryTooLargeError(f"Repository is {final_size / (1024 ** 3):.2f} GB which exceeds the 3 GB limit")
|
||||
yield f"Clone complete: {final_size / (1024 * 1024):.1f} MB on disk"
|
||||
98
devplacepy/services/jobs/isslop/acquisition/source.py
Normal file
98
devplacepy/services/jobs/isslop/acquisition/source.py
Normal file
@ -0,0 +1,98 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from devplacepy.services.jobs.isslop.config import GIT_PROBE_TIMEOUT_SECONDS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KIND_GIT: str = "git"
|
||||
KIND_WEBSITE: str = "website"
|
||||
|
||||
GIT_URL_PATTERN = re.compile(r"(\.git$|^git://|^ssh://git@|^git@)", re.IGNORECASE)
|
||||
GIT_HOST_HINTS: tuple[str, ...] = ("github.com", "gitlab.com", "bitbucket.org", "codeberg.org", "gitea.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceResolution:
|
||||
url: str
|
||||
kind: str
|
||||
probe_output: str
|
||||
|
||||
|
||||
def normalize_url(url: str) -> str:
|
||||
url = url.strip()
|
||||
if re.match(r"^git@[\w.-]+:", url):
|
||||
return url
|
||||
if not re.match(r"^[a-z]+://", url, re.IGNORECASE):
|
||||
url = f"https://{url}"
|
||||
return url
|
||||
|
||||
|
||||
def is_private_host(url: str) -> bool:
|
||||
parsed = urlparse(url if "://" in url else f"ssh://{url}")
|
||||
host = parsed.hostname or ""
|
||||
if not host:
|
||||
return True
|
||||
if host in ("localhost",):
|
||||
return True
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
return address.is_private or address.is_loopback or address.is_link_local or address.is_reserved
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
resolved = socket.getaddrinfo(host, None)
|
||||
except socket.gaierror:
|
||||
return False
|
||||
for entry in resolved:
|
||||
candidate = ipaddress.ip_address(entry[4][0])
|
||||
if candidate.is_private or candidate.is_loopback or candidate.is_link_local:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def probe_git(url: str) -> tuple[bool, str]:
|
||||
argv = ["git", "ls-remote", "--heads", "--exit-code", url]
|
||||
logger.info("Probing for git repository: %s", url)
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env={"GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "true", "PATH": "/usr/bin:/bin:/usr/local/bin"},
|
||||
)
|
||||
except OSError as error:
|
||||
logger.error("git probe spawn failed: %s", error)
|
||||
return False, str(error)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=GIT_PROBE_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
logger.warning("git probe timed out for %s", url)
|
||||
return False, "probe timeout"
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
errors = stderr.decode("utf-8", errors="replace")
|
||||
is_git = proc.returncode == 0 and bool(output.strip())
|
||||
logger.info("git probe %s: is_git=%s refs=%d", url, is_git, len(output.splitlines()))
|
||||
return is_git, output if is_git else errors[:500]
|
||||
|
||||
|
||||
async def resolve_source(url: str) -> SourceResolution:
|
||||
normalized = normalize_url(url)
|
||||
hinted = bool(GIT_URL_PATTERN.search(normalized)) or any(hint in normalized.lower() for hint in GIT_HOST_HINTS)
|
||||
is_git, probe_output = await probe_git(normalized)
|
||||
if is_git:
|
||||
return SourceResolution(url=normalized, kind=KIND_GIT, probe_output=probe_output)
|
||||
if hinted:
|
||||
logger.info("URL hinted git but probe failed, treating as website: %s", normalized)
|
||||
return SourceResolution(url=normalized, kind=KIND_WEBSITE, probe_output=probe_output)
|
||||
315
devplacepy/services/jobs/isslop/acquisition/website.py
Normal file
315
devplacepy/services/jobs/isslop/acquisition/website.py
Normal file
@ -0,0 +1,315 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
from urllib.parse import urldefrag, urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy.net_guard import BlockedAddressError, guard_public_url
|
||||
from devplacepy.stealth import stealth_async_client
|
||||
from devplacepy.services.jobs.isslop.acquisition.domcapture import DomSnapshot
|
||||
from devplacepy.services.jobs.isslop.acquisition.workspace import safe_relative_path
|
||||
from devplacepy.services.jobs.isslop.config import (
|
||||
DOM_ANALYSIS_MAX_PAGES,
|
||||
WEBSITE_MAX_DEPTH,
|
||||
WEBSITE_MAX_FILE_BYTES,
|
||||
WEBSITE_MAX_FILES,
|
||||
WEBSITE_MAX_PAGES,
|
||||
WEBSITE_MAX_TOTAL_BYTES,
|
||||
WEBSITE_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USER_AGENT: str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
|
||||
TEXT_CONTENT_HINTS: tuple[str, ...] = ("text/", "javascript", "json", "xml", "svg", "css")
|
||||
SKIP_SCHEMES: tuple[str, ...] = ("mailto:", "tel:", "javascript:", "data:", "#")
|
||||
|
||||
|
||||
class LinkExtractor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.page_links: list[str] = []
|
||||
self.asset_links: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
mapping = dict(attrs)
|
||||
if tag == "a" and mapping.get("href"):
|
||||
self.page_links.append(str(mapping["href"]))
|
||||
if tag in ("script", "img", "source", "iframe") and mapping.get("src"):
|
||||
self.asset_links.append(str(mapping["src"]))
|
||||
if tag == "link" and mapping.get("href"):
|
||||
self.asset_links.append(str(mapping["href"]))
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrawlState:
|
||||
pages_fetched: int = 0
|
||||
files_saved: int = 0
|
||||
bytes_saved: int = 0
|
||||
visited: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
def _clean_url(base: str, href: str) -> str:
|
||||
joined = urljoin(base, href)
|
||||
return urldefrag(joined).url
|
||||
|
||||
|
||||
def _local_path_for(workspace: Path, url: str) -> Path:
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path or "/"
|
||||
if path.endswith("/"):
|
||||
path += "index.html"
|
||||
name = path.lstrip("/") or "index.html"
|
||||
if "." not in name.split("/")[-1]:
|
||||
name += ".html"
|
||||
if parsed.query:
|
||||
name += "-" + "".join(ch for ch in parsed.query if ch.isalnum())[:40]
|
||||
return safe_relative_path(workspace, name)
|
||||
|
||||
|
||||
def _is_text_response(content_type: str) -> bool:
|
||||
lowered = content_type.lower()
|
||||
return any(hint in lowered for hint in TEXT_CONTENT_HINTS)
|
||||
|
||||
|
||||
def _same_site(candidate: str, root_host: str | None) -> bool:
|
||||
host = urlparse(candidate).hostname
|
||||
if host is None or root_host is None:
|
||||
return False
|
||||
return host == root_host or host.endswith("." + root_host) or root_host.endswith("." + host)
|
||||
|
||||
|
||||
def _extract_links(base: str, html: str, root_host: str | None) -> tuple[list[str], list[str]]:
|
||||
extractor = LinkExtractor()
|
||||
try:
|
||||
extractor.feed(html)
|
||||
except ValueError as error:
|
||||
logger.debug("HTML parse issue on %s: %s", base, error)
|
||||
pages: list[str] = []
|
||||
assets: list[str] = []
|
||||
for asset in extractor.asset_links:
|
||||
cleaned = _clean_url(base, asset)
|
||||
if _same_site(cleaned, root_host):
|
||||
assets.append(cleaned)
|
||||
for link in extractor.page_links:
|
||||
cleaned = _clean_url(base, link)
|
||||
if _same_site(cleaned, root_host):
|
||||
pages.append(cleaned)
|
||||
return pages, assets
|
||||
|
||||
|
||||
def _save_bytes(workspace: Path, url: str, body: bytes) -> Path | None:
|
||||
try:
|
||||
target = _local_path_for(workspace, url)
|
||||
except ValueError as error:
|
||||
logger.warning("Rejected path for %s: %s", url, error)
|
||||
return None
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(body)
|
||||
return target
|
||||
|
||||
|
||||
async def crawl_website(
|
||||
url: str,
|
||||
workspace: Path,
|
||||
*,
|
||||
dom_sink: list[DomSnapshot] | None = None,
|
||||
allow_private: bool = False,
|
||||
) -> AsyncIterator[str]:
|
||||
from devplacepy.services.jobs.isslop.acquisition.browser import browser_available
|
||||
|
||||
if browser_available():
|
||||
try:
|
||||
async for progress in _crawl_with_browser(
|
||||
url, workspace, dom_sink=dom_sink, allow_private=allow_private
|
||||
):
|
||||
yield progress
|
||||
return
|
||||
except RuntimeError as error:
|
||||
logger.warning("Stealth browser crawl failed, falling back to HTTP client: %s", error)
|
||||
yield f"Stealth browser unavailable ({error}); falling back to HTTP client"
|
||||
async for progress in _crawl_with_http(url, workspace):
|
||||
yield progress
|
||||
|
||||
|
||||
async def _crawl_with_browser(
|
||||
url: str,
|
||||
workspace: Path,
|
||||
*,
|
||||
dom_sink: list[DomSnapshot] | None = None,
|
||||
allow_private: bool = False,
|
||||
) -> AsyncIterator[str]:
|
||||
from devplacepy.services.jobs.isslop.acquisition.browser import StealthBrowser
|
||||
|
||||
root_host = urlparse(url).hostname
|
||||
state = CrawlState()
|
||||
yield f"Launching stealth browser for {url} (max depth {WEBSITE_MAX_DEPTH}, max {WEBSITE_MAX_FILES} files)"
|
||||
async with StealthBrowser(tab_concurrency=4) as browser:
|
||||
yield "Stealth browser ready; rendering pages with an undetectable Chromium fingerprint"
|
||||
current_level: list[str] = [url]
|
||||
for depth in range(WEBSITE_MAX_DEPTH + 1):
|
||||
pending = [
|
||||
candidate
|
||||
for candidate in current_level
|
||||
if candidate not in state.visited and _same_site(candidate, root_host)
|
||||
and not any(candidate.lower().startswith(scheme) for scheme in SKIP_SCHEMES)
|
||||
]
|
||||
if not pending or state.files_saved >= WEBSITE_MAX_FILES:
|
||||
break
|
||||
pending = pending[: max(0, WEBSITE_MAX_PAGES - state.pages_fetched)]
|
||||
safe_candidates: list[str] = []
|
||||
for candidate in pending:
|
||||
try:
|
||||
await guard_public_url(candidate, allow_private=allow_private)
|
||||
except BlockedAddressError:
|
||||
state.visited.add(candidate)
|
||||
yield f"Blocked private/local address: {candidate}"
|
||||
continue
|
||||
safe_candidates.append(candidate)
|
||||
for candidate in safe_candidates:
|
||||
state.visited.add(candidate)
|
||||
|
||||
async def _render_or_capture(index: int, candidate: str) -> object:
|
||||
if depth == 0 and dom_sink is not None and index < DOM_ANALYSIS_MAX_PAGES:
|
||||
snapshot = await browser.capture(candidate)
|
||||
dom_sink.append(snapshot)
|
||||
return snapshot.render
|
||||
return await browser.render(candidate)
|
||||
|
||||
renders = await asyncio.gather(
|
||||
*(_render_or_capture(index, candidate) for index, candidate in enumerate(safe_candidates))
|
||||
)
|
||||
asset_urls: set[str] = set()
|
||||
next_level: list[str] = []
|
||||
for render in renders:
|
||||
if not render.ok:
|
||||
yield f"Render failed: {render.url} ({render.error or 'no content'})"
|
||||
continue
|
||||
body = render.html.encode("utf-8", errors="replace")[:WEBSITE_MAX_FILE_BYTES]
|
||||
target = _save_bytes(workspace, render.final_url, body)
|
||||
if target is None:
|
||||
continue
|
||||
state.files_saved += 1
|
||||
state.bytes_saved += len(body)
|
||||
state.pages_fetched += 1
|
||||
yield f"Rendered {target.relative_to(workspace)} ({len(body)} bytes, DOM after JS execution)"
|
||||
pages, assets = _extract_links(render.final_url, render.html, root_host)
|
||||
for asset in assets:
|
||||
if asset not in state.visited:
|
||||
asset_urls.add(asset)
|
||||
next_level.extend(pages)
|
||||
async for progress in _download_assets(browser, workspace, sorted(asset_urls), state):
|
||||
yield progress
|
||||
current_level = next_level
|
||||
if state.bytes_saved >= WEBSITE_MAX_TOTAL_BYTES:
|
||||
yield f"Byte budget reached: {state.bytes_saved / 1024:.1f} KB"
|
||||
break
|
||||
if state.files_saved == 0:
|
||||
raise RuntimeError(f"Stealth crawl produced no files for {url}")
|
||||
yield (
|
||||
f"Website download complete: {state.files_saved} files, {state.bytes_saved / 1024:.1f} KB, "
|
||||
f"{state.pages_fetched} rendered pages"
|
||||
)
|
||||
|
||||
|
||||
async def _download_assets(
|
||||
browser: object,
|
||||
workspace: Path,
|
||||
asset_urls: list[str],
|
||||
state: CrawlState,
|
||||
) -> AsyncIterator[str]:
|
||||
budget = min(len(asset_urls), max(0, WEBSITE_MAX_FILES - state.files_saved))
|
||||
if budget <= 0:
|
||||
return
|
||||
targets = [asset for asset in asset_urls if asset not in state.visited][:budget]
|
||||
for asset in targets:
|
||||
state.visited.add(asset)
|
||||
results = await asyncio.gather(*(browser.fetch(asset, WEBSITE_MAX_FILE_BYTES) for asset in targets))
|
||||
saved = 0
|
||||
for result in results:
|
||||
if not result.ok or not result.body:
|
||||
continue
|
||||
target = _save_bytes(workspace, result.url, result.body)
|
||||
if target is None:
|
||||
continue
|
||||
state.files_saved += 1
|
||||
state.bytes_saved += len(result.body)
|
||||
saved += 1
|
||||
if saved:
|
||||
yield f"Downloaded {saved} assets concurrently (scripts, styles, sources)"
|
||||
|
||||
|
||||
async def _crawl_with_http(url: str, workspace: Path) -> AsyncIterator[str]:
|
||||
root = urlparse(url)
|
||||
state = CrawlState()
|
||||
queue: list[tuple[str, int]] = [(url, 0)]
|
||||
headers = {"user-agent": USER_AGENT, "accept": "*/*"}
|
||||
yield f"Crawling website {url} with HTTP client (max depth {WEBSITE_MAX_DEPTH}, max {WEBSITE_MAX_FILES} files)"
|
||||
async with stealth_async_client(
|
||||
timeout=WEBSITE_REQUEST_TIMEOUT_SECONDS,
|
||||
follow_redirects=True,
|
||||
headers=headers,
|
||||
) as client:
|
||||
while queue:
|
||||
if state.files_saved >= WEBSITE_MAX_FILES or state.bytes_saved >= WEBSITE_MAX_TOTAL_BYTES:
|
||||
yield f"Crawl limits reached: {state.files_saved} files, {state.bytes_saved} bytes"
|
||||
break
|
||||
current, depth = queue.pop(0)
|
||||
if current in state.visited:
|
||||
continue
|
||||
state.visited.add(current)
|
||||
parsed = urlparse(current)
|
||||
if parsed.hostname != root.hostname:
|
||||
continue
|
||||
if any(current.lower().startswith(scheme) for scheme in SKIP_SCHEMES):
|
||||
continue
|
||||
try:
|
||||
response = await client.get(current)
|
||||
except httpx.HTTPError as error:
|
||||
logger.warning("Fetch failed %s: %s", current, error)
|
||||
yield f"Fetch failed: {current} ({error})"
|
||||
continue
|
||||
if response.status_code >= 400:
|
||||
yield f"Skipped {current}: HTTP {response.status_code}"
|
||||
continue
|
||||
content_type = response.headers.get("content-type", "")
|
||||
body = response.content[:WEBSITE_MAX_FILE_BYTES]
|
||||
try:
|
||||
target = _local_path_for(workspace, current)
|
||||
except ValueError as error:
|
||||
logger.warning("Rejected path for %s: %s", current, error)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(body)
|
||||
state.files_saved += 1
|
||||
state.bytes_saved += len(body)
|
||||
yield f"Saved {target.relative_to(workspace)} ({len(body)} bytes, {content_type.split(';')[0] or 'unknown type'})"
|
||||
|
||||
is_page = "text/html" in content_type.lower()
|
||||
if is_page:
|
||||
state.pages_fetched += 1
|
||||
if depth < WEBSITE_MAX_DEPTH and state.pages_fetched < WEBSITE_MAX_PAGES:
|
||||
extractor = LinkExtractor()
|
||||
try:
|
||||
extractor.feed(body.decode("utf-8", errors="replace"))
|
||||
except ValueError as error:
|
||||
logger.debug("HTML parse issue on %s: %s", current, error)
|
||||
for asset in extractor.asset_links:
|
||||
cleaned = _clean_url(current, asset)
|
||||
if urlparse(cleaned).hostname == root.hostname and cleaned not in state.visited:
|
||||
queue.append((cleaned, depth + 1))
|
||||
for link in extractor.page_links:
|
||||
cleaned = _clean_url(current, link)
|
||||
if urlparse(cleaned).hostname == root.hostname and cleaned not in state.visited:
|
||||
queue.append((cleaned, depth + 1))
|
||||
if state.files_saved == 0:
|
||||
raise RuntimeError(f"Website download produced no files for {url}")
|
||||
yield f"Website download complete: {state.files_saved} files, {state.bytes_saved / 1024:.1f} KB, {state.pages_fetched} pages"
|
||||
85
devplacepy/services/jobs/isslop/acquisition/workspace.py
Normal file
85
devplacepy/services/jobs/isslop/acquisition/workspace.py
Normal file
@ -0,0 +1,85 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SLUG_MAX_LENGTH: int = 80
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
value = re.sub(r"^[a-z]+://", "", value.strip().lower())
|
||||
value = re.sub(r"[^a-z0-9._-]+", "-", value).strip("-._")
|
||||
if not value:
|
||||
value = "source"
|
||||
return value[:SLUG_MAX_LENGTH]
|
||||
|
||||
|
||||
def workspace_for(workspaces_dir: Path, source_url: str, analysis_uid: str) -> Path:
|
||||
digest = hashlib.sha256(source_url.encode("utf-8")).hexdigest()[:10]
|
||||
slug = slugify(source_url)
|
||||
workspace = (workspaces_dir / f"{slug}-{digest}-{analysis_uid}").resolve()
|
||||
if workspaces_dir.resolve() != workspace.parent:
|
||||
raise ValueError(f"Workspace path escapes workspaces directory: {workspace}")
|
||||
logger.info("Workspace resolved: %s", workspace)
|
||||
return workspace
|
||||
|
||||
|
||||
def reset_workspace(workspace: Path) -> Path:
|
||||
if workspace.exists():
|
||||
logger.info("Removing existing workspace %s", workspace)
|
||||
shutil.rmtree(workspace)
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
return workspace
|
||||
|
||||
|
||||
def safe_relative_path(workspace: Path, candidate: str) -> Path:
|
||||
cleaned = re.sub(r"[^A-Za-z0-9._/-]+", "-", candidate).strip("/")
|
||||
cleaned = re.sub(r"\.\.+", ".", cleaned)
|
||||
target = (workspace / cleaned).resolve()
|
||||
if workspace.resolve() not in target.parents and target != workspace.resolve():
|
||||
raise ValueError(f"Refusing path traversal: {candidate}")
|
||||
return target
|
||||
|
||||
|
||||
def remove_workspace(workspace: Path) -> bool:
|
||||
if not workspace.exists():
|
||||
return False
|
||||
try:
|
||||
shutil.rmtree(workspace)
|
||||
except OSError as error:
|
||||
logger.error("Workspace removal failed for %s: %s", workspace, error)
|
||||
return False
|
||||
logger.info("Workspace removed: %s", workspace)
|
||||
return True
|
||||
|
||||
|
||||
def directory_size_bytes(path: Path) -> int:
|
||||
total = 0
|
||||
for entry in path.rglob("*"):
|
||||
if entry.is_file() and not entry.is_symlink():
|
||||
try:
|
||||
total += entry.stat().st_size
|
||||
except OSError as error:
|
||||
logger.debug("Size stat failed for %s: %s", entry, error)
|
||||
return total
|
||||
|
||||
|
||||
def content_hash(workspace: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
entries: list[tuple[str, Path]] = []
|
||||
for entry in workspace.rglob("*"):
|
||||
if entry.is_file() and not entry.is_symlink() and ".git" not in entry.parts:
|
||||
entries.append((str(entry.relative_to(workspace)), entry))
|
||||
for relative, entry in sorted(entries):
|
||||
digest.update(relative.encode("utf-8"))
|
||||
try:
|
||||
digest.update(hashlib.sha256(entry.read_bytes()).digest())
|
||||
except OSError as error:
|
||||
logger.debug("Hash read failed for %s: %s", entry, error)
|
||||
return digest.hexdigest()
|
||||
1
devplacepy/services/jobs/isslop/agent/__init__.py
Normal file
1
devplacepy/services/jobs/isslop/agent/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
111
devplacepy/services/jobs/isslop/agent/classifier.py
Normal file
111
devplacepy/services/jobs/isslop/agent/classifier.py
Normal file
@ -0,0 +1,111 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.services.jobs.isslop.agent.llm import LlmClient, LlmUnavailableError, extract_json_object
|
||||
from devplacepy.services.jobs.isslop.analysis.scoring import (
|
||||
CATEGORY_HUMAN_CLEAN,
|
||||
CATEGORY_HUMAN_MESSY,
|
||||
CATEGORY_SLOP,
|
||||
CATEGORY_SOPHISTICATED,
|
||||
CATEGORY_UNCERTAIN,
|
||||
FileScore,
|
||||
)
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import SEVERITY_STRONG
|
||||
from devplacepy.services.jobs.isslop.config import AI_EXCERPT_CHARS, AI_SAMPLE_LIMIT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_CATEGORIES: frozenset[str] = frozenset(
|
||||
{CATEGORY_SLOP, CATEGORY_SOPHISTICATED, CATEGORY_HUMAN_CLEAN, CATEGORY_HUMAN_MESSY, CATEGORY_UNCERTAIN}
|
||||
)
|
||||
|
||||
SYSTEM_PROMPT: str = (
|
||||
"You are a deterministic source-code provenance and quality auditor. "
|
||||
"You classify code on two independent axes: origin (was it AI-generated) and "
|
||||
"curation quality (was it reviewed, integrated and production-worthy). "
|
||||
"The categories are: ai-slop (AI origin, low curation: the model's defaults shipped as-is), "
|
||||
"sophisticated-ai (AI origin, high curation: the model typed, the engineer decided), "
|
||||
"human-clean and human-messy (code no model would write that way), uncertain. "
|
||||
"Human messiness is not slop. Clean AI-assisted code is not slop. "
|
||||
"Judge only from the evidence given. Respond with a single JSON object and nothing else, using exactly these keys: "
|
||||
'{"origin_score": <0-100 integer, likelihood of AI generation>, '
|
||||
'"quality_deficit": <0-100 integer, density of anti-patterns and missing curation>, '
|
||||
'"ai_probability": <0-100 integer>, '
|
||||
'"category": <one of ai-slop|sophisticated-ai|human-clean|human-messy|uncertain>, '
|
||||
'"reasoning": <two to four factual sentences citing concrete evidence>, '
|
||||
'"notable_signals": [<up to five short strings>]}'
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AiVerdict:
|
||||
path: str
|
||||
origin_score: float
|
||||
quality_deficit: float
|
||||
ai_probability: float
|
||||
category: str
|
||||
reasoning: str
|
||||
notable_signals: list[str]
|
||||
|
||||
|
||||
def select_samples(scores: list[FileScore], limit: int = AI_SAMPLE_LIMIT) -> list[FileScore]:
|
||||
def interest(entry: FileScore) -> tuple[float, str]:
|
||||
strong = sum(1 for signal in entry.signals if signal.severity == SEVERITY_STRONG)
|
||||
return (-(entry.sloc * (1.0 + strong) * entry.criticality), entry.relative)
|
||||
|
||||
ranked = sorted((entry for entry in scores if entry.sloc >= 10), key=interest)
|
||||
return ranked[:limit]
|
||||
|
||||
|
||||
def _clamp(value: object, fallback: float) -> float:
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return float(min(100.0, max(0.0, value)))
|
||||
return fallback
|
||||
|
||||
|
||||
async def classify_file(
|
||||
llm: LlmClient,
|
||||
score: FileScore,
|
||||
excerpt: str,
|
||||
) -> Optional[AiVerdict]:
|
||||
signal_lines = "\n".join(
|
||||
f"- [{signal.severity}] {signal.code} line {signal.line}: {signal.title}"
|
||||
for signal in score.signals[:15]
|
||||
) or "- none detected"
|
||||
user = (
|
||||
f"File: {score.relative}\n"
|
||||
f"Language: {score.language}\n"
|
||||
f"Static origin score: {score.origin_score}\n"
|
||||
f"Static quality deficit: {score.quality_deficit}\n"
|
||||
f"Static signals:\n{signal_lines}\n\n"
|
||||
f"Source excerpt (may be truncated):\n```\n{excerpt[:AI_EXCERPT_CHARS]}\n```"
|
||||
)
|
||||
try:
|
||||
answer = await llm.complete(SYSTEM_PROMPT, user)
|
||||
except LlmUnavailableError as error:
|
||||
logger.warning("AI classification unavailable for %s: %s", score.relative, error)
|
||||
return None
|
||||
parsed = extract_json_object(answer)
|
||||
if parsed is None:
|
||||
logger.warning("AI classification returned unparseable output for %s", score.relative)
|
||||
return None
|
||||
category = str(parsed.get("category", CATEGORY_UNCERTAIN)).strip().lower()
|
||||
if category not in VALID_CATEGORIES:
|
||||
category = CATEGORY_UNCERTAIN
|
||||
signals_field = parsed.get("notable_signals")
|
||||
notable = [str(item)[:120] for item in signals_field[:5]] if isinstance(signals_field, list) else []
|
||||
verdict = AiVerdict(
|
||||
path=score.relative,
|
||||
origin_score=_clamp(parsed.get("origin_score"), score.origin_score),
|
||||
quality_deficit=_clamp(parsed.get("quality_deficit"), score.quality_deficit),
|
||||
ai_probability=_clamp(parsed.get("ai_probability"), score.origin_score),
|
||||
category=category,
|
||||
reasoning=str(parsed.get("reasoning", ""))[:1200],
|
||||
notable_signals=notable,
|
||||
)
|
||||
logger.info("AI verdict for %s: %s (ai %d%%)", verdict.path, verdict.category, int(verdict.ai_probability))
|
||||
return verdict
|
||||
159
devplacepy/services/jobs/isslop/agent/llm.py
Normal file
159
devplacepy/services/jobs/isslop/agent/llm.py
Normal file
@ -0,0 +1,159 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy.stealth import stealth_async_client
|
||||
from devplacepy.services.jobs.isslop.config import (
|
||||
LLM_MAX_RETRIES,
|
||||
LLM_RETRY_BACKOFF_SECONDS,
|
||||
LLM_TEMPERATURE,
|
||||
LLM_TIMEOUT_SECONDS,
|
||||
WorkerSettings,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LlmUnavailableError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class LlmClient:
|
||||
def __init__(self, settings: WorkerSettings) -> None:
|
||||
self._endpoint = settings.llm_endpoint
|
||||
self._model = settings.llm_model
|
||||
self._api_key = settings.llm_api_key
|
||||
self._review_enabled = settings.ai_review_enabled
|
||||
self._vision_enabled = settings.image_review_enabled
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
@property
|
||||
def active_backend_name(self) -> str:
|
||||
return self._model
|
||||
|
||||
@property
|
||||
def review_available(self) -> bool:
|
||||
return self._review_enabled and bool(self._endpoint and self._api_key)
|
||||
|
||||
@property
|
||||
def vision_available(self) -> bool:
|
||||
return self._vision_enabled and bool(self._endpoint and self._api_key)
|
||||
|
||||
async def _http(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = stealth_async_client(timeout=httpx.Timeout(LLM_TIMEOUT_SECONDS))
|
||||
return self._client
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._client is not None:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def _call(self, messages: list[dict[str, Any]], timeout: Optional[float] = None) -> str:
|
||||
if not self._endpoint or not self._api_key:
|
||||
raise LlmUnavailableError("AI gateway not configured")
|
||||
client = await self._http()
|
||||
payload = {
|
||||
"model": self._model,
|
||||
"messages": messages,
|
||||
"temperature": LLM_TEMPERATURE,
|
||||
}
|
||||
headers = {"authorization": f"Bearer {self._api_key}", "content-type": "application/json"}
|
||||
request_timeout = httpx.Timeout(timeout) if timeout else None
|
||||
last_error = "unknown"
|
||||
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
||||
try:
|
||||
response = await client.post(
|
||||
self._endpoint, json=payload, headers=headers, timeout=request_timeout
|
||||
)
|
||||
except httpx.HTTPError as error:
|
||||
last_error = f"{type(error).__name__}: {error}"
|
||||
logger.warning("Gateway attempt %d transport failure: %s", attempt, last_error)
|
||||
await asyncio.sleep(LLM_RETRY_BACKOFF_SECONDS * attempt)
|
||||
continue
|
||||
if response.status_code >= 400:
|
||||
last_error = f"HTTP {response.status_code}: {response.text[:300]}"
|
||||
logger.warning("Gateway attempt %d failed: %s", attempt, last_error)
|
||||
await asyncio.sleep(LLM_RETRY_BACKOFF_SECONDS * attempt)
|
||||
continue
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
last_error = "invalid JSON envelope"
|
||||
continue
|
||||
choices = body.get("choices")
|
||||
if isinstance(choices, list) and choices:
|
||||
content = choices[0].get("message", {}).get("content")
|
||||
if isinstance(content, str) and content.strip():
|
||||
return content
|
||||
last_error = json.dumps(body)[:300]
|
||||
logger.warning("Gateway attempt %d empty completion: %s", attempt, last_error)
|
||||
await asyncio.sleep(LLM_RETRY_BACKOFF_SECONDS * attempt)
|
||||
raise LlmUnavailableError(f"AI gateway failed after {LLM_MAX_RETRIES} attempts: {last_error}")
|
||||
|
||||
async def complete(self, system: str, user: str) -> str:
|
||||
if not self.review_available:
|
||||
raise LlmUnavailableError("AI review disabled")
|
||||
return await self._call(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
]
|
||||
)
|
||||
|
||||
async def describe_image(self, prompt: str, image_data_url: str, timeout: float) -> str:
|
||||
if not self.vision_available:
|
||||
raise LlmUnavailableError("vision backend unavailable")
|
||||
return await self._call(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": image_data_url}},
|
||||
],
|
||||
}
|
||||
],
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def extract_json_object(text: str) -> Optional[dict[str, Any]]:
|
||||
start = text.find("{")
|
||||
while start != -1:
|
||||
depth = 0
|
||||
in_string = False
|
||||
escaped = False
|
||||
for position in range(start, len(text)):
|
||||
char = text[position]
|
||||
if in_string:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char == '"':
|
||||
in_string = False
|
||||
continue
|
||||
if char == '"':
|
||||
in_string = True
|
||||
elif char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
candidate = text[start:position + 1]
|
||||
try:
|
||||
parsed = json.loads(candidate)
|
||||
except json.JSONDecodeError:
|
||||
break
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
break
|
||||
start = text.find("{", start + 1)
|
||||
return None
|
||||
246
devplacepy/services/jobs/isslop/agent/reporter.py
Normal file
246
devplacepy/services/jobs/isslop/agent/reporter.py
Normal file
@ -0,0 +1,246 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.services.jobs.isslop.agent.classifier import AiVerdict
|
||||
from devplacepy.services.jobs.isslop.agent.llm import LlmClient, LlmUnavailableError
|
||||
from devplacepy.services.jobs.isslop.agent.vision import ImageVerdict
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.aggregate import DomEvidence
|
||||
from devplacepy.services.jobs.isslop.analysis.scoring import FileScore, RepoScores
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import SEVERITY_STRONG
|
||||
from devplacepy.services.jobs.isslop.analysis.templates import TemplateEvidence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REPORT_SYSTEM_PROMPT: str = (
|
||||
"You are a technical auditor writing the final report of a source-code AI usage classification. "
|
||||
"Write factual, professional markdown. No emoji, no marketing language, no hedging filler. "
|
||||
"Structure: '# AI Usage Classification Report' title, then sections 'Verdict', 'Scores', "
|
||||
"'Evidence', 'Notable Files', 'Image Review' (only if image data is present), 'Methodology Notes', 'Caveats'. "
|
||||
"Cite concrete files and signals from the data, and wrap EVERY file path you mention in backticks; "
|
||||
"when citing a specific line, write it as `path:line` (like `src/libs/Env.ts:12`) so the platform can "
|
||||
"link the reference to the exact annotated source line. "
|
||||
"Present the result as calibrated guidance, not accusation. "
|
||||
"Category definitions: ai-slop ships the model's defaults untouched; sophisticated-ai shows AI steered by "
|
||||
"an engineer who knows what they are doing; human is code no model would write that way. "
|
||||
"When template_provenance markers are present, explain that the project ships a starter template's "
|
||||
"defaults and name the template evidence; shipping scaffold defaults counts against authenticity. "
|
||||
"A near-certain unmodified starter template IS ai-slop by definition: the defaults were shipped as-is, "
|
||||
"and clean scaffold code does not change that - the curation belongs to the template author, not the "
|
||||
"presenter. Never call an untouched template sophisticated-ai. "
|
||||
"When dom_evidence.detected_builder is present, lead the Verdict section with a line stating the site was "
|
||||
"detected as built with that no-code builder, treating the rendered-page fingerprint match as near-certain "
|
||||
"evidence. "
|
||||
"Keep it under 900 words. Output only markdown."
|
||||
)
|
||||
|
||||
|
||||
def _summary_payload(
|
||||
source_url: str,
|
||||
source_kind: str,
|
||||
scores: RepoScores,
|
||||
files: list[FileScore],
|
||||
verdicts: list[AiVerdict],
|
||||
excluded_count: int,
|
||||
image_verdicts: list[ImageVerdict],
|
||||
image_stats: dict[str, Any],
|
||||
template: TemplateEvidence,
|
||||
dom_evidence: DomEvidence | None,
|
||||
) -> dict[str, Any]:
|
||||
worst = sorted(files, key=lambda entry: (-entry.quality_deficit, entry.relative))[:10]
|
||||
strongest = [
|
||||
{
|
||||
"path": entry.relative,
|
||||
"origin": entry.origin_score,
|
||||
"quality_deficit": entry.quality_deficit,
|
||||
"category": entry.category,
|
||||
"signals": [signal.to_dict() for signal in entry.signals if signal.severity == SEVERITY_STRONG][:4],
|
||||
}
|
||||
for entry in worst
|
||||
]
|
||||
return {
|
||||
"source_url": source_url,
|
||||
"source_kind": source_kind,
|
||||
"repo_scores": {
|
||||
"grade": scores.grade,
|
||||
"slop_score": scores.slop_score,
|
||||
"origin_score": scores.origin_score,
|
||||
"quality_deficit": scores.quality_deficit,
|
||||
"category": scores.category,
|
||||
"human_percent": scores.human_percent,
|
||||
"ai_percent": scores.ai_percent,
|
||||
"confidence": scores.confidence,
|
||||
"files_scored": scores.files_scored,
|
||||
"excluded_files": excluded_count,
|
||||
"strong_signals": scores.strong_signal_count,
|
||||
"medium_signals": scores.medium_signal_count,
|
||||
},
|
||||
"worst_files": strongest,
|
||||
"ai_verdicts": [
|
||||
{
|
||||
"path": verdict.path,
|
||||
"category": verdict.category,
|
||||
"ai_probability": verdict.ai_probability,
|
||||
"reasoning": verdict.reasoning,
|
||||
"notable_signals": verdict.notable_signals,
|
||||
}
|
||||
for verdict in verdicts
|
||||
],
|
||||
"template_provenance": {
|
||||
"score": template.score,
|
||||
"markers": template.markers,
|
||||
},
|
||||
"image_review": image_stats,
|
||||
"image_verdicts": [
|
||||
{
|
||||
"path": verdict.relative,
|
||||
"grade": verdict.grade,
|
||||
"verdict": verdict.verdict,
|
||||
"ai_probability": verdict.ai_probability,
|
||||
"image_kind": verdict.image_kind,
|
||||
"tells": verdict.tells,
|
||||
"description": verdict.description,
|
||||
}
|
||||
for verdict in sorted(image_verdicts, key=lambda item: -item.ai_probability)[:12]
|
||||
],
|
||||
"dom_evidence": (
|
||||
{
|
||||
"detected_builder": dom_evidence.detected_builder,
|
||||
"builder_confidence": dom_evidence.builder_confidence,
|
||||
"dom_slop_score": dom_evidence.score,
|
||||
"bucket": dom_evidence.bucket,
|
||||
"top_signals": [signal.to_dict() for signal in dom_evidence.signals[:6]],
|
||||
}
|
||||
if dom_evidence is not None
|
||||
else {"detected_builder": None, "builder_confidence": 0.0, "dom_slop_score": 0.0, "bucket": "none", "top_signals": []}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def fallback_report(payload: dict[str, Any]) -> str:
|
||||
repo = payload["repo_scores"]
|
||||
lines: list[str] = [
|
||||
"# AI Usage Classification Report",
|
||||
"",
|
||||
f"Source: `{payload['source_url']}` ({payload['source_kind']})",
|
||||
"",
|
||||
"## Verdict",
|
||||
"",
|
||||
f"Category **{repo['category']}** with grade **{repo['grade']}** "
|
||||
f"(slop score {repo['slop_score']}/100, confidence {repo['confidence']}).",
|
||||
]
|
||||
dom_evidence = payload.get("dom_evidence") or {}
|
||||
if dom_evidence.get("detected_builder"):
|
||||
lines.append(
|
||||
f"Detected: built with {dom_evidence['detected_builder']} (rendered-page fingerprint, "
|
||||
f"confidence {dom_evidence.get('builder_confidence', 0.0):.0%})."
|
||||
)
|
||||
lines.extend([
|
||||
f"Estimated composition: **{repo['human_percent']}% human**, **{repo['ai_percent']}% AI**.",
|
||||
"",
|
||||
"## Scores",
|
||||
"",
|
||||
"| Metric | Value |",
|
||||
"| --- | --- |",
|
||||
f"| Grade | {repo['grade']} |",
|
||||
f"| Slop score | {repo['slop_score']} / 100 |",
|
||||
f"| Origin score | {repo['origin_score']} / 100 |",
|
||||
f"| Quality deficit | {repo['quality_deficit']} / 100 |",
|
||||
f"| Human share | {repo['human_percent']}% |",
|
||||
f"| AI share | {repo['ai_percent']}% |",
|
||||
f"| Files scored | {repo['files_scored']} |",
|
||||
f"| Files excluded | {repo['excluded_files']} |",
|
||||
f"| Strong signals | {repo['strong_signals']} |",
|
||||
f"| Medium signals | {repo['medium_signals']} |",
|
||||
"",
|
||||
"## Notable Files",
|
||||
"",
|
||||
])
|
||||
for entry in payload["worst_files"]:
|
||||
lines.append(
|
||||
f"- `{entry['path']}`: {entry['category']}, origin {entry['origin']}, quality deficit {entry['quality_deficit']}"
|
||||
)
|
||||
for signal in entry["signals"]:
|
||||
lines.append(f" - [{signal['severity']}] {signal['title']} (line {signal['line']})")
|
||||
template = payload.get("template_provenance", {})
|
||||
if template.get("markers"):
|
||||
lines.extend(["", "## Template Provenance", ""])
|
||||
lines.append(
|
||||
f"Starter template evidence score **{template.get('score')}/100**. The authenticity grade is "
|
||||
"reduced accordingly: an unmodified starter ships its scaffold's defaults rather than original work."
|
||||
)
|
||||
for marker in template["markers"]:
|
||||
lines.append(f"- {marker}")
|
||||
if payload["ai_verdicts"]:
|
||||
lines.extend(["", "## AI Review Findings", ""])
|
||||
for verdict in payload["ai_verdicts"]:
|
||||
lines.append(f"- `{verdict['path']}`: {verdict['category']} ({verdict['ai_probability']}% AI). {verdict['reasoning']}")
|
||||
image_review = payload.get("image_review", {})
|
||||
if payload.get("image_verdicts"):
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Image Review",
|
||||
"",
|
||||
f"Reviewed {image_review.get('count', 0)} images; "
|
||||
f"{image_review.get('ai_generated_count', 0)} appear AI-generated "
|
||||
f"(mean AI likelihood {image_review.get('mean_ai_probability', 0)}%, image grade {image_review.get('grade', 'n/a')}).",
|
||||
"",
|
||||
]
|
||||
)
|
||||
for verdict in payload["image_verdicts"]:
|
||||
tells = f" Tells: {', '.join(verdict['tells'])}." if verdict["tells"] else ""
|
||||
lines.append(
|
||||
f"- `{verdict['path']}` ({verdict['image_kind']}): grade {verdict['grade']}, {verdict['verdict']} "
|
||||
f"({verdict['ai_probability']}% AI). {verdict['description']}{tells}"
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Caveats",
|
||||
"",
|
||||
"No detector is definitive. This report combines weighted static signals with an AI review pass "
|
||||
"and should be read as calibrated guidance rather than proof of provenance.",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def generate_report(
|
||||
llm: LlmClient,
|
||||
source_url: str,
|
||||
source_kind: str,
|
||||
scores: RepoScores,
|
||||
files: list[FileScore],
|
||||
verdicts: list[AiVerdict],
|
||||
excluded_count: int,
|
||||
image_verdicts: list[ImageVerdict],
|
||||
image_stats: dict[str, Any],
|
||||
template: TemplateEvidence,
|
||||
dom_evidence: DomEvidence | None,
|
||||
) -> tuple[str, str]:
|
||||
payload = _summary_payload(
|
||||
source_url,
|
||||
source_kind,
|
||||
scores,
|
||||
files,
|
||||
verdicts,
|
||||
excluded_count,
|
||||
image_verdicts,
|
||||
image_stats,
|
||||
template,
|
||||
dom_evidence,
|
||||
)
|
||||
user = "Write the final report for this classification data:\n" + json.dumps(payload, indent=2)[:24000]
|
||||
try:
|
||||
markdown = await llm.complete(REPORT_SYSTEM_PROMPT, user)
|
||||
if "# " not in markdown:
|
||||
raise LlmUnavailableError("report missing markdown structure")
|
||||
logger.info("Report generated via %s (%d chars)", llm.active_backend_name, len(markdown))
|
||||
return markdown.strip(), llm.active_backend_name
|
||||
except LlmUnavailableError as error:
|
||||
logger.warning("LLM report generation failed, using deterministic fallback: %s", error)
|
||||
return fallback_report(payload), "static-fallback"
|
||||
215
devplacepy/services/jobs/isslop/agent/vision.py
Normal file
215
devplacepy/services/jobs/isslop/agent/vision.py
Normal file
@ -0,0 +1,215 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from devplacepy.services.jobs.isslop.agent.llm import LlmClient, LlmUnavailableError, extract_json_object
|
||||
from devplacepy.services.jobs.isslop.config import (
|
||||
IMAGE_CONCURRENCY,
|
||||
IMAGE_EXTENSIONS,
|
||||
IMAGE_MAX_BYTES,
|
||||
IMAGE_MAX_COUNT,
|
||||
IMAGE_MIN_BYTES,
|
||||
IMAGE_MIN_DIMENSION,
|
||||
THUMBNAIL_MAX_DIMENSION,
|
||||
THUMBNAIL_QUALITY,
|
||||
VISION_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VISION_PROMPT: str = (
|
||||
"You are a forensic image analyst deciding whether an image is AI-generated (or heavily AI-edited) or a genuine "
|
||||
"human-made photograph, illustration or screenshot. Examine, in detail: hands and fingers, faces and teeth, skin "
|
||||
"texture (waxy or plastic over-smoothing), eyes and reflections, text and lettering (garbled or nonsensical), "
|
||||
"lighting and shadow direction consistency, background warping and impossible geometry, repeated or cloned patterns, "
|
||||
"over-saturated or airbrushed rendering, and the 'too perfect' mathematically smooth look typical of diffusion models. "
|
||||
"Screenshots, logos, diagrams, icons and real product photos are usually not AI-generated; judge accordingly and do "
|
||||
"not call an ordinary screenshot AI art. Respond with a single JSON object only, using exactly these keys: "
|
||||
'{"ai_probability": <integer 0-100, likelihood the image is AI-generated>, '
|
||||
'"verdict": <one of "ai-generated"|"likely-ai"|"uncertain"|"likely-human"|"human-made">, '
|
||||
'"image_kind": <short phrase, e.g. "photo of a person", "screenshot", "logo", "illustration">, '
|
||||
'"tells": [<up to five short strings naming concrete artifacts or reasons>], '
|
||||
'"description": <two to four factual sentences describing the image and the evidence>}'
|
||||
)
|
||||
|
||||
VERDICT_VALUES: frozenset[str] = frozenset(
|
||||
{"ai-generated", "likely-ai", "uncertain", "likely-human", "human-made"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageVerdict:
|
||||
relative: str
|
||||
ai_probability: float
|
||||
grade: str
|
||||
verdict: str
|
||||
image_kind: str
|
||||
tells: list[str]
|
||||
description: str
|
||||
|
||||
|
||||
def _grade_for(ai_probability: float) -> str:
|
||||
if ai_probability <= 15:
|
||||
return "A"
|
||||
if ai_probability <= 35:
|
||||
return "B"
|
||||
if ai_probability <= 55:
|
||||
return "C"
|
||||
if ai_probability <= 75:
|
||||
return "D"
|
||||
return "F"
|
||||
|
||||
|
||||
def _eligible(path: Path) -> bool:
|
||||
if path.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
return False
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
return False
|
||||
if size < IMAGE_MIN_BYTES or size > IMAGE_MAX_BYTES:
|
||||
return False
|
||||
try:
|
||||
with Image.open(path) as image:
|
||||
width, height = image.size
|
||||
except (UnidentifiedImageError, OSError, ValueError) as error:
|
||||
logger.debug("Skipping unreadable image %s: %s", path, error)
|
||||
return False
|
||||
if width < IMAGE_MIN_DIMENSION or height < IMAGE_MIN_DIMENSION:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def collect_images(workspace: Path, limit: int = IMAGE_MAX_COUNT) -> list[Path]:
|
||||
candidates = [
|
||||
path
|
||||
for path in sorted(workspace.rglob("*"))
|
||||
if path.is_file() and not path.is_symlink() and _eligible(path)
|
||||
]
|
||||
if len(candidates) <= limit:
|
||||
logger.info("Collected %d images for vision analysis", len(candidates))
|
||||
return candidates
|
||||
ranked = sorted(
|
||||
candidates,
|
||||
key=lambda path: hashlib.sha256(str(path.relative_to(workspace)).encode("utf-8")).hexdigest(),
|
||||
)
|
||||
sample = sorted(ranked[:limit])
|
||||
logger.info("Sampled %d of %d images deterministically for vision analysis", len(sample), len(candidates))
|
||||
return sample
|
||||
|
||||
|
||||
def _to_data_url(path: Path) -> Optional[str]:
|
||||
mime_type = mimetypes.guess_type(path.name)[0]
|
||||
if not mime_type or not mime_type.startswith("image/"):
|
||||
return None
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as error:
|
||||
logger.warning("Image read failed for %s: %s", path, error)
|
||||
return None
|
||||
return f"data:{mime_type};base64,{base64.b64encode(raw).decode('utf-8')}"
|
||||
|
||||
|
||||
async def classify_image(llm: LlmClient, workspace: Path, path: Path) -> Optional[ImageVerdict]:
|
||||
relative = str(path.relative_to(workspace))
|
||||
data_url = await asyncio.to_thread(_to_data_url, path)
|
||||
if data_url is None:
|
||||
return None
|
||||
try:
|
||||
answer = await llm.describe_image(VISION_PROMPT, data_url, VISION_TIMEOUT_SECONDS)
|
||||
except LlmUnavailableError as error:
|
||||
logger.warning("Vision unavailable for %s: %s", relative, error)
|
||||
return None
|
||||
parsed = extract_json_object(answer)
|
||||
if parsed is None:
|
||||
logger.warning("Vision returned unparseable output for %s", relative)
|
||||
return None
|
||||
probability = parsed.get("ai_probability")
|
||||
if not isinstance(probability, (int, float)) or isinstance(probability, bool):
|
||||
probability = 50.0
|
||||
probability = float(min(100.0, max(0.0, probability)))
|
||||
verdict = str(parsed.get("verdict", "uncertain")).strip().lower()
|
||||
if verdict not in VERDICT_VALUES:
|
||||
verdict = "uncertain"
|
||||
tells_field = parsed.get("tells")
|
||||
tells = [str(item)[:120] for item in tells_field[:5]] if isinstance(tells_field, list) else []
|
||||
return ImageVerdict(
|
||||
relative=relative,
|
||||
ai_probability=probability,
|
||||
grade=_grade_for(probability),
|
||||
verdict=verdict,
|
||||
image_kind=str(parsed.get("image_kind", "image"))[:80],
|
||||
tells=tells,
|
||||
description=str(parsed.get("description", ""))[:1000],
|
||||
)
|
||||
|
||||
|
||||
async def analyze_images(
|
||||
llm: LlmClient,
|
||||
workspace: Path,
|
||||
images: list[Path],
|
||||
) -> list[ImageVerdict]:
|
||||
semaphore = asyncio.Semaphore(IMAGE_CONCURRENCY)
|
||||
|
||||
async def worker(path: Path) -> Optional[ImageVerdict]:
|
||||
async with semaphore:
|
||||
return await classify_image(llm, workspace, path)
|
||||
|
||||
results = await asyncio.gather(*(worker(path) for path in images))
|
||||
verdicts = [result for result in results if result is not None]
|
||||
logger.info("Vision analysis produced %d verdicts from %d images", len(verdicts), len(images))
|
||||
return verdicts
|
||||
|
||||
|
||||
def make_thumbnail(source: Path, media_dir: Path, relative: str) -> Optional[str]:
|
||||
name = f"{hashlib.sha1(relative.encode('utf-8')).hexdigest()[:16]}.webp"
|
||||
try:
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
with Image.open(source) as image:
|
||||
image.thumbnail((THUMBNAIL_MAX_DIMENSION, THUMBNAIL_MAX_DIMENSION))
|
||||
if image.mode not in ("RGB", "RGBA"):
|
||||
image = image.convert("RGBA")
|
||||
image.save(media_dir / name, format="WEBP", quality=THUMBNAIL_QUALITY)
|
||||
except (UnidentifiedImageError, OSError, ValueError) as error:
|
||||
logger.warning("Thumbnail failed for %s: %s", relative, error)
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
def persist_screenshot(raw_bytes: bytes, media_dir: Path, relative: str) -> Optional[str]:
|
||||
name = f"{hashlib.sha1(relative.encode('utf-8')).hexdigest()[:16]}.webp"
|
||||
try:
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
with Image.open(io.BytesIO(raw_bytes)) as image:
|
||||
image.thumbnail((THUMBNAIL_MAX_DIMENSION, THUMBNAIL_MAX_DIMENSION))
|
||||
if image.mode not in ("RGB", "RGBA"):
|
||||
image = image.convert("RGBA")
|
||||
image.save(media_dir / name, format="WEBP", quality=THUMBNAIL_QUALITY)
|
||||
except (UnidentifiedImageError, OSError, ValueError) as error:
|
||||
logger.warning("Screenshot persist failed for %s: %s", relative, error)
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
def image_summary(verdicts: list[ImageVerdict]) -> dict[str, float | int | str]:
|
||||
if not verdicts:
|
||||
return {"count": 0, "mean_ai_probability": 0.0, "grade": "n/a", "ai_generated_count": 0}
|
||||
mean = sum(verdict.ai_probability for verdict in verdicts) / len(verdicts)
|
||||
ai_generated = sum(1 for verdict in verdicts if verdict.ai_probability >= 60.0)
|
||||
return {
|
||||
"count": len(verdicts),
|
||||
"mean_ai_probability": round(mean, 1),
|
||||
"grade": _grade_for(mean),
|
||||
"ai_generated_count": ai_generated,
|
||||
}
|
||||
1
devplacepy/services/jobs/isslop/analysis/__init__.py
Normal file
1
devplacepy/services/jobs/isslop/analysis/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
74
devplacepy/services/jobs/isslop/analysis/baselines.py
Normal file
74
devplacepy/services/jobs/isslop/analysis/baselines.py
Normal file
@ -0,0 +1,74 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.metrics import FileMetrics
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import AXIS_ORIGIN, SEVERITY_MEDIUM, Signal
|
||||
|
||||
MIN_FILES_FOR_BASELINE: int = 8
|
||||
DEVIATION_Z_THRESHOLD: float = 2.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RepoBaselines:
|
||||
file_count: int
|
||||
indent_variance_mean: float
|
||||
indent_variance_std: float
|
||||
comment_ratio_mean: float
|
||||
comment_ratio_std: float
|
||||
blank_ratio_mean: float
|
||||
blank_ratio_std: float
|
||||
|
||||
|
||||
def _mean_std(values: list[float]) -> tuple[float, float]:
|
||||
if not values:
|
||||
return 0.0, 0.0
|
||||
mean = sum(values) / len(values)
|
||||
variance = sum((value - mean) ** 2 for value in values) / len(values)
|
||||
return mean, math.sqrt(variance)
|
||||
|
||||
|
||||
def compute_baselines(metrics_by_file: dict[str, FileMetrics]) -> RepoBaselines:
|
||||
indent = [metrics.indent_variance for metrics in metrics_by_file.values() if metrics.sloc > 10]
|
||||
comment = [metrics.comment_ratio for metrics in metrics_by_file.values() if metrics.sloc > 10]
|
||||
blank = [metrics.blank_ratio for metrics in metrics_by_file.values() if metrics.sloc > 10]
|
||||
indent_mean, indent_std = _mean_std(indent)
|
||||
comment_mean, comment_std = _mean_std(comment)
|
||||
blank_mean, blank_std = _mean_std(blank)
|
||||
return RepoBaselines(
|
||||
file_count=len(indent),
|
||||
indent_variance_mean=indent_mean,
|
||||
indent_variance_std=indent_std,
|
||||
comment_ratio_mean=comment_mean,
|
||||
comment_ratio_std=comment_std,
|
||||
blank_ratio_mean=blank_mean,
|
||||
blank_ratio_std=blank_std,
|
||||
)
|
||||
|
||||
|
||||
def deviation_signals(relative: str, metrics: FileMetrics, baselines: RepoBaselines) -> list[Signal]:
|
||||
if baselines.file_count < MIN_FILES_FOR_BASELINE or metrics.sloc <= 10:
|
||||
return []
|
||||
scores: list[float] = []
|
||||
if baselines.indent_variance_std > 0.01:
|
||||
scores.append((baselines.indent_variance_mean - metrics.indent_variance) / baselines.indent_variance_std)
|
||||
if baselines.comment_ratio_std > 0.01:
|
||||
scores.append((metrics.comment_ratio - baselines.comment_ratio_mean) / baselines.comment_ratio_std)
|
||||
if not scores:
|
||||
return []
|
||||
composite = sum(scores) / len(scores)
|
||||
if composite >= DEVIATION_Z_THRESHOLD:
|
||||
return [
|
||||
Signal(
|
||||
code="CONVENTION_DEVIATION",
|
||||
title=f"File is markedly cleaner and more regular than repo baseline (z={composite:.1f})",
|
||||
severity=SEVERITY_MEDIUM,
|
||||
axis=AXIS_ORIGIN,
|
||||
weight=3.0,
|
||||
line=1,
|
||||
evidence=f"indent deviation {metrics.indent_variance:.2f} vs repo mean {baselines.indent_variance_mean:.2f}",
|
||||
)
|
||||
]
|
||||
return []
|
||||
@ -0,0 +1,2 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
@ -0,0 +1,98 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.base import DomPageContext, dom_check
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import AXIS_QUALITY, SEVERITY_WEAK, Signal
|
||||
|
||||
GENERIC_ALT_PATTERN = re.compile(r"^(image of|photo of|picture of)\b", re.IGNORECASE)
|
||||
DUPLICATE_ALT_THRESHOLD: int = 3
|
||||
EMPTY_ALT_THRESHOLD: int = 3
|
||||
|
||||
|
||||
def _detect_generic_alt_text(images: list[dict]) -> Signal | None:
|
||||
empty_count = 0
|
||||
for image in images:
|
||||
alt = str(image.get("alt", "")).strip() if isinstance(image, dict) else ""
|
||||
if not alt:
|
||||
empty_count += 1
|
||||
continue
|
||||
if GENERIC_ALT_PATTERN.match(alt):
|
||||
return Signal(
|
||||
"GENERIC_ALT_TEXT",
|
||||
"Generic placeholder-style alt text on an image",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
0,
|
||||
alt[:100],
|
||||
)
|
||||
if empty_count >= EMPTY_ALT_THRESHOLD and len(images) >= EMPTY_ALT_THRESHOLD:
|
||||
return Signal(
|
||||
"GENERIC_ALT_TEXT",
|
||||
f"{empty_count} images with empty alt text",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
0,
|
||||
f"{empty_count} empty alt attributes",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_duplicate_alt_text(images: list[dict]) -> Signal | None:
|
||||
counts: dict[str, int] = {}
|
||||
for image in images:
|
||||
alt = str(image.get("alt", "")).strip() if isinstance(image, dict) else ""
|
||||
if alt:
|
||||
counts[alt] = counts.get(alt, 0) + 1
|
||||
for alt, count in counts.items():
|
||||
if count >= DUPLICATE_ALT_THRESHOLD:
|
||||
return Signal(
|
||||
"DUPLICATE_ALT_TEXT",
|
||||
f"{count} images share the exact same alt text",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
0,
|
||||
alt[:100],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_skipped_heading_level(headings: list[dict]) -> Signal | None:
|
||||
levels = [
|
||||
heading.get("level")
|
||||
for heading in headings
|
||||
if isinstance(heading, dict) and isinstance(heading.get("level"), int)
|
||||
]
|
||||
if len(levels) < 2 or levels[0] != 1:
|
||||
return None
|
||||
next_level = levels[1]
|
||||
if isinstance(next_level, int) and next_level > 2:
|
||||
return Signal(
|
||||
"SKIPPED_HEADING_LEVEL",
|
||||
f"H1 followed directly by H{next_level} with no H2 in between",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_QUALITY,
|
||||
0.5,
|
||||
0,
|
||||
f"H1 -> H{next_level}",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@dom_check
|
||||
def detect_accessibility_signals(page: DomPageContext) -> list[Signal]:
|
||||
images = page.dom.get("images", []) or []
|
||||
headings = page.dom.get("headings", []) or []
|
||||
findings: list[Signal] = []
|
||||
for signal in (
|
||||
_detect_generic_alt_text(images),
|
||||
_detect_duplicate_alt_text(images),
|
||||
_detect_skipped_heading_level(headings),
|
||||
):
|
||||
if signal is not None:
|
||||
findings.append(signal)
|
||||
return findings
|
||||
@ -0,0 +1,58 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.base import DomPageContext, DomSiteContext
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.builders import detected_builder
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.registry import run_dom_checks, run_dom_site_checks
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import SEVERITY_FACTORS, Signal
|
||||
|
||||
DOM_SATURATION: float = 16.0
|
||||
DOM_FINDINGS_CAP: int = 14
|
||||
|
||||
BUCKET_NONE: str = "none"
|
||||
BUCKET_LIGHT: str = "light"
|
||||
BUCKET_MODERATE: str = "moderate"
|
||||
BUCKET_HEAVY: str = "heavy"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DomEvidence:
|
||||
score: float
|
||||
bucket: str
|
||||
signals: list[Signal] = field(default_factory=list)
|
||||
detected_builder: str | None = None
|
||||
builder_confidence: float = 0.0
|
||||
|
||||
|
||||
def _bucket_for(score: float) -> str:
|
||||
if score < 10.0:
|
||||
return BUCKET_NONE
|
||||
if score < 35.0:
|
||||
return BUCKET_LIGHT
|
||||
if score < 65.0:
|
||||
return BUCKET_MODERATE
|
||||
return BUCKET_HEAVY
|
||||
|
||||
|
||||
def aggregate_dom_evidence(pages: list[DomPageContext]) -> DomEvidence:
|
||||
if not pages:
|
||||
return DomEvidence(score=0.0, bucket=BUCKET_NONE, signals=[], detected_builder=None, builder_confidence=0.0)
|
||||
signals: list[Signal] = []
|
||||
for page in pages:
|
||||
signals.extend(run_dom_checks(page)[:DOM_FINDINGS_CAP])
|
||||
site = DomSiteContext(pages=pages, root_host=urlparse(pages[0].url).hostname or "")
|
||||
signals.extend(run_dom_site_checks(site))
|
||||
weighted = sum(signal.weight * SEVERITY_FACTORS.get(signal.severity, 0.3) for signal in signals)
|
||||
score = round(100.0 * (1.0 - math.exp(-weighted / DOM_SATURATION)), 1)
|
||||
builder_name, builder_confidence = detected_builder(pages)
|
||||
return DomEvidence(
|
||||
score=score,
|
||||
bucket=_bucket_for(score),
|
||||
signals=signals,
|
||||
detected_builder=builder_name,
|
||||
builder_confidence=builder_confidence,
|
||||
)
|
||||
41
devplacepy/services/jobs/isslop/analysis/domsignals/base.py
Normal file
41
devplacepy/services/jobs/isslop/analysis/domsignals/base.py
Normal file
@ -0,0 +1,41 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import Signal
|
||||
|
||||
|
||||
@dataclass
|
||||
class DomPageContext:
|
||||
url: str
|
||||
dom: dict = field(default_factory=dict)
|
||||
console_warnings: list[str] = field(default_factory=list)
|
||||
console_errors: list[str] = field(default_factory=list)
|
||||
response_headers: dict[str, str] = field(default_factory=dict)
|
||||
resource_hosts: list[str] = field(default_factory=list)
|
||||
screenshot_bytes: bytes | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DomSiteContext:
|
||||
pages: list[DomPageContext] = field(default_factory=list)
|
||||
root_host: str = ""
|
||||
|
||||
|
||||
DomPageDetector = Callable[[DomPageContext], list[Signal]]
|
||||
DomSiteDetector = Callable[[DomSiteContext], list[Signal]]
|
||||
|
||||
DOM_CHECKS: list[DomPageDetector] = []
|
||||
DOM_SITE_CHECKS: list[DomSiteDetector] = []
|
||||
|
||||
|
||||
def dom_check(func: DomPageDetector) -> DomPageDetector:
|
||||
DOM_CHECKS.append(func)
|
||||
return func
|
||||
|
||||
|
||||
def dom_site_check(func: DomSiteDetector) -> DomSiteDetector:
|
||||
DOM_SITE_CHECKS.append(func)
|
||||
return func
|
||||
204
devplacepy/services/jobs/isslop/analysis/domsignals/builders.py
Normal file
204
devplacepy/services/jobs/isslop/analysis/domsignals/builders.py
Normal file
@ -0,0 +1,204 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.base import DomPageContext, dom_check
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_ORIGIN,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_STRONG,
|
||||
SEVERITY_WEAK,
|
||||
Signal,
|
||||
)
|
||||
|
||||
CLAUDE_ARTIFACT_BADGE_KEY: str = 'iframe[src*="claudeusercontent.com"]'
|
||||
|
||||
FRAMER_RESOURCE_HOSTS: frozenset[str] = frozenset(
|
||||
{"events.framer.com", "framerusercontent.com", "framercdn.com", "framercanvas.com"}
|
||||
)
|
||||
WIX_RESOURCE_HOSTS: frozenset[str] = frozenset(
|
||||
{"static.wixstatic.com", "static.parastorage.com", "siteassets.parastorage.com"}
|
||||
)
|
||||
WEBFLOW_RESOURCE_HOSTS: frozenset[str] = frozenset(
|
||||
{"assets-global.website-files.com", "uploads-ssl.webflow.com", "assets.website-files.com"}
|
||||
)
|
||||
SQUARESPACE_RESOURCE_HOSTS: frozenset[str] = frozenset(
|
||||
{"static1.squarespace.com", "static.squarespace.com", "images.squarespace-cdn.com"}
|
||||
)
|
||||
HOSTING_HEADER_KEYS: frozenset[str] = frozenset({"x-vercel-id", "x-nf-request-id"})
|
||||
|
||||
|
||||
def _host_of(url: str) -> str:
|
||||
return (urlparse(url).hostname or "").lower()
|
||||
|
||||
|
||||
def _resource_text(page: DomPageContext) -> str:
|
||||
scripts = page.dom.get("scripts", []) or []
|
||||
links = page.dom.get("links", []) or []
|
||||
hosts = page.resource_hosts or []
|
||||
return " ".join(str(item) for item in (*scripts, *links, *hosts)).lower()
|
||||
|
||||
|
||||
def _generator(page: DomPageContext) -> str:
|
||||
meta = page.dom.get("meta", {}) or {}
|
||||
return str(meta.get("generator") or "")
|
||||
|
||||
|
||||
def _class_names(page: DomPageContext) -> list[str]:
|
||||
return list((page.dom.get("classNames") or {}).keys())
|
||||
|
||||
|
||||
def _badge(page: DomPageContext, selector: str) -> bool:
|
||||
return bool((page.dom.get("badgeHits") or {}).get(selector))
|
||||
|
||||
|
||||
def _detect_lovable(page: DomPageContext) -> Signal | None:
|
||||
badge = _badge(page, "#lovable-badge")
|
||||
resource_hit = "cdn.gpteng.co" in _resource_text(page) or "gptengineer.js" in _resource_text(page)
|
||||
generator_hit = "lovable" in _generator(page).lower()
|
||||
host_hit = _host_of(page.url).endswith(".lovable.app")
|
||||
if not (badge or resource_hit or generator_hit or host_hit):
|
||||
return None
|
||||
evidence = "lovable-badge" if badge else "cdn.gpteng.co" if resource_hit else "generator meta" if generator_hit else "lovable.app host"
|
||||
return Signal("AI_BUILDER_LOVABLE", "Lovable / GPT Engineer builder fingerprint", SEVERITY_STRONG, AXIS_ORIGIN, 4.0, 0, evidence)
|
||||
|
||||
|
||||
def _detect_bolt(page: DomPageContext) -> Signal | None:
|
||||
haystack = _resource_text(page) + " " + page.url.lower()
|
||||
if "bolt.new" not in haystack:
|
||||
return None
|
||||
return Signal("AI_BUILDER_BOLT", "Bolt.new builder fingerprint", SEVERITY_STRONG, AXIS_ORIGIN, 4.0, 0, "bolt.new")
|
||||
|
||||
|
||||
def _detect_replit(page: DomPageContext) -> Signal | None:
|
||||
badge = _badge(page, ".replit-badge")
|
||||
host = _host_of(page.url)
|
||||
host_hit = host.endswith(".replit.app") or host.endswith(".repl.co")
|
||||
if not (badge or host_hit):
|
||||
return None
|
||||
return Signal("AI_BUILDER_REPLIT", "Replit builder fingerprint", SEVERITY_STRONG, AXIS_ORIGIN, 4.0, 0, "replit-badge" if badge else host)
|
||||
|
||||
|
||||
def _detect_base44(page: DomPageContext) -> Signal | None:
|
||||
if not _badge(page, "#base44-badge"):
|
||||
return None
|
||||
return Signal("AI_BUILDER_BASE44", "Base44 builder fingerprint", SEVERITY_STRONG, AXIS_ORIGIN, 4.0, 0, "base44-badge")
|
||||
|
||||
|
||||
def _detect_framer(page: DomPageContext) -> Signal | None:
|
||||
badge = _badge(page, "#__framer-badge-container")
|
||||
generator_hit = "framer" in _generator(page).lower()
|
||||
resource_hit = bool(set(page.resource_hosts or []) & FRAMER_RESOURCE_HOSTS)
|
||||
host_hit = _host_of(page.url).endswith(".framer.website")
|
||||
if not (badge or generator_hit or resource_hit or host_hit):
|
||||
return None
|
||||
evidence = "framer-badge" if badge else "generator meta" if generator_hit else "framer resource host" if resource_hit else "framer.website host"
|
||||
return Signal("AI_BUILDER_FRAMER", "Framer builder fingerprint", SEVERITY_STRONG, AXIS_ORIGIN, 4.0, 0, evidence)
|
||||
|
||||
|
||||
def _detect_wix(page: DomPageContext) -> Signal | None:
|
||||
generator_hit = "wix.com website builder" in _generator(page).lower()
|
||||
resource_hit = bool(set(page.resource_hosts or []) & WIX_RESOURCE_HOSTS)
|
||||
class_hit = any("wix-bolt" in name.lower() for name in _class_names(page))
|
||||
if not (generator_hit or resource_hit or class_hit):
|
||||
return None
|
||||
evidence = "generator meta" if generator_hit else "wix resource host" if resource_hit else "wix-bolt class"
|
||||
return Signal("AI_BUILDER_WIX", "Wix builder fingerprint", SEVERITY_STRONG, AXIS_ORIGIN, 4.0, 0, evidence)
|
||||
|
||||
|
||||
def _detect_webflow(page: DomPageContext) -> Signal | None:
|
||||
generator_hit = "webflow" in _generator(page).lower()
|
||||
resource_hit = bool(set(page.resource_hosts or []) & WEBFLOW_RESOURCE_HOSTS)
|
||||
if not (generator_hit or resource_hit):
|
||||
return None
|
||||
return Signal("AI_BUILDER_WEBFLOW", "Webflow builder fingerprint", SEVERITY_STRONG, AXIS_ORIGIN, 4.0, 0, "generator meta" if generator_hit else "webflow resource host")
|
||||
|
||||
|
||||
def _detect_godaddy(page: DomPageContext) -> Signal | None:
|
||||
if "go daddy website builder" not in _generator(page).lower():
|
||||
return None
|
||||
return Signal("AI_BUILDER_GODADDY", "GoDaddy Website Builder fingerprint", SEVERITY_STRONG, AXIS_ORIGIN, 4.0, 0, "generator meta")
|
||||
|
||||
|
||||
def _detect_squarespace(page: DomPageContext) -> Signal | None:
|
||||
resource_hit = bool(set(page.resource_hosts or []) & SQUARESPACE_RESOURCE_HOSTS)
|
||||
class_hit = any(name.lower().startswith("sqs-block") or name.lower().startswith("yui3-") for name in _class_names(page))
|
||||
if not (resource_hit or class_hit):
|
||||
return None
|
||||
return Signal("AI_BUILDER_SQUARESPACE", "Squarespace builder fingerprint", SEVERITY_STRONG, AXIS_ORIGIN, 4.0, 0, "squarespace resource host" if resource_hit else "sqs-block class")
|
||||
|
||||
|
||||
def _detect_claude_artifact(page: DomPageContext) -> Signal | None:
|
||||
if not _badge(page, CLAUDE_ARTIFACT_BADGE_KEY):
|
||||
return None
|
||||
return Signal("AI_BUILDER_CLAUDE_ARTIFACT", "Claude Artifacts iframe embed detected", SEVERITY_STRONG, AXIS_ORIGIN, 4.0, 0, "claudeusercontent.com iframe")
|
||||
|
||||
|
||||
def _detect_shadcn_radix_cluster(page: DomPageContext) -> Signal | None:
|
||||
indicators: list[str] = []
|
||||
if _badge(page, "[data-radix-root]"):
|
||||
indicators.append("data-radix-root")
|
||||
if any("radix" in name.lower() for name in _class_names(page)):
|
||||
indicators.append("radix class token")
|
||||
if "lucide" in _resource_text(page):
|
||||
indicators.append("lucide icons")
|
||||
if len(indicators) < 2:
|
||||
return None
|
||||
severity = SEVERITY_STRONG if len(indicators) >= 3 else SEVERITY_MEDIUM
|
||||
return Signal("SHADCN_RADIX_CLUSTER", f"shadcn/ui and Radix primitives cluster ({len(indicators)} indicators)", severity, AXIS_ORIGIN, 3.0, 0, ", ".join(indicators))
|
||||
|
||||
|
||||
def _detect_hosting_subdomain(page: DomPageContext) -> Signal | None:
|
||||
host = _host_of(page.url)
|
||||
host_hit = host.endswith(".vercel.app") or host.endswith(".netlify.app") or host.endswith(".databutton.app")
|
||||
headers = {key.lower() for key in (page.response_headers or {}).keys()}
|
||||
header_hit = bool(headers & HOSTING_HEADER_KEYS)
|
||||
if not (host_hit or header_hit):
|
||||
return None
|
||||
evidence = host if host_hit else ", ".join(sorted(headers & HOSTING_HEADER_KEYS))
|
||||
return Signal("BUILDER_HOSTING_SUBDOMAIN", "Hosted on a default builder/PaaS subdomain", SEVERITY_WEAK, AXIS_ORIGIN, 1.0, 0, evidence)
|
||||
|
||||
|
||||
_VENDOR_DETECTORS: tuple[tuple[Callable[[DomPageContext], Signal | None], str], ...] = (
|
||||
(_detect_lovable, "Lovable"),
|
||||
(_detect_bolt, "Bolt.new"),
|
||||
(_detect_replit, "Replit"),
|
||||
(_detect_base44, "Base44"),
|
||||
(_detect_framer, "Framer"),
|
||||
(_detect_wix, "Wix"),
|
||||
(_detect_webflow, "Webflow"),
|
||||
(_detect_godaddy, "GoDaddy"),
|
||||
(_detect_squarespace, "Squarespace"),
|
||||
(_detect_claude_artifact, "Claude Artifacts"),
|
||||
)
|
||||
|
||||
|
||||
@dom_check
|
||||
def detect_builders(page: DomPageContext) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
for detector, _label in _VENDOR_DETECTORS:
|
||||
signal = detector(page)
|
||||
if signal is not None:
|
||||
findings.append(signal)
|
||||
cluster = _detect_shadcn_radix_cluster(page)
|
||||
if cluster is not None:
|
||||
findings.append(cluster)
|
||||
hosting = _detect_hosting_subdomain(page)
|
||||
if hosting is not None:
|
||||
findings.append(hosting)
|
||||
return findings
|
||||
|
||||
|
||||
def detected_builder(pages: list[DomPageContext]) -> tuple[str | None, float]:
|
||||
cluster_hit = False
|
||||
for page in pages:
|
||||
for detector, label in _VENDOR_DETECTORS:
|
||||
if detector(page) is not None:
|
||||
return label, 1.0
|
||||
if _detect_shadcn_radix_cluster(page) is not None:
|
||||
cluster_hit = True
|
||||
if cluster_hit:
|
||||
return "shadcn/ui + Radix", 0.5
|
||||
return None, 0.0
|
||||
@ -0,0 +1,185 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.base import DomPageContext, dom_check
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_ORIGIN,
|
||||
AXIS_QUALITY,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_STRONG,
|
||||
SEVERITY_WEAK,
|
||||
Signal,
|
||||
)
|
||||
|
||||
HASHED_SEGMENT_PATTERN = re.compile(r"[.-][0-9a-f]{6,}\.js", re.IGNORECASE)
|
||||
UNHASHED_BUNDLE_PATTERN = re.compile(r"/(main|bundle|index)\.js(\?|$)", re.IGNORECASE)
|
||||
PLACEHOLDER_TEXT_PATTERN = re.compile(
|
||||
r"lorem ipsum|coming soon|your headline here|add your description|placeholder", re.IGNORECASE
|
||||
)
|
||||
PLACEHOLDER_IMAGE_HOSTS: tuple[str, ...] = ("via.placeholder.com", "placehold.co", "picsum.photos")
|
||||
TAILWIND_PRODUCTION_WARNING: str = "cdn.tailwindcss.com should not be used in production"
|
||||
REACT_DEV_WARNING_MARKERS: tuple[str, ...] = ("development build of React", "Each child in a list should have a unique")
|
||||
|
||||
|
||||
def _text_blob(page: DomPageContext) -> str:
|
||||
parts = [
|
||||
str(heading.get("text", ""))
|
||||
for heading in (page.dom.get("headings") or [])
|
||||
if isinstance(heading, dict) and heading.get("text")
|
||||
]
|
||||
meta = page.dom.get("meta", {}) or {}
|
||||
description = meta.get("description") if isinstance(meta, dict) else ""
|
||||
if description:
|
||||
parts.append(str(description))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _looks_unhashed(src: str) -> bool:
|
||||
if not src:
|
||||
return False
|
||||
if HASHED_SEGMENT_PATTERN.search(src):
|
||||
return False
|
||||
return bool(UNHASHED_BUNDLE_PATTERN.search(src))
|
||||
|
||||
|
||||
def _detect_tailwind_cdn(page: DomPageContext) -> Signal | None:
|
||||
scripts = page.dom.get("scripts", []) or []
|
||||
if not any("cdn.tailwindcss.com" in str(src) for src in scripts):
|
||||
return None
|
||||
console_text = " ".join(page.console_warnings + page.console_errors)
|
||||
if TAILWIND_PRODUCTION_WARNING in console_text:
|
||||
return Signal(
|
||||
"TAILWIND_CDN_DOM",
|
||||
"Tailwind CDN build flagged unsuitable for production by the browser console",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_ORIGIN,
|
||||
3.5,
|
||||
0,
|
||||
"cdn.tailwindcss.com + production warning",
|
||||
)
|
||||
return Signal(
|
||||
"TAILWIND_CDN_DOM",
|
||||
"Tailwind loaded from the CDN build in the rendered page",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
0,
|
||||
"cdn.tailwindcss.com",
|
||||
)
|
||||
|
||||
|
||||
def _detect_cdn_react(page: DomPageContext) -> Signal | None:
|
||||
scripts = page.dom.get("scripts", []) or []
|
||||
for src in scripts:
|
||||
text = str(src)
|
||||
if "unpkg.com/react" in text or "esm.sh/react" in text or "esm.sh/tsx" in text:
|
||||
return Signal(
|
||||
"CDN_REACT_UNBUNDLED",
|
||||
"React loaded unbundled directly from a CDN",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
0,
|
||||
text[:150],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_react_dev_warning(page: DomPageContext) -> Signal | None:
|
||||
console_text = " ".join(page.console_warnings + page.console_errors)
|
||||
if not any(marker in console_text for marker in REACT_DEV_WARNING_MARKERS):
|
||||
return None
|
||||
return Signal(
|
||||
"REACT_DEV_BUILD_WARNING",
|
||||
"React development-build or missing-key warnings present in the console",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
1.5,
|
||||
0,
|
||||
console_text[:150],
|
||||
)
|
||||
|
||||
|
||||
def _detect_unhashed_bundle(page: DomPageContext) -> Signal | None:
|
||||
scripts = page.dom.get("scripts", []) or []
|
||||
for src in scripts:
|
||||
if _looks_unhashed(str(src)):
|
||||
return Signal(
|
||||
"UNHASHED_BUNDLE_FILENAME",
|
||||
"Script bundle filename with no content-hash segment",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_QUALITY,
|
||||
0.5,
|
||||
0,
|
||||
str(src),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_placeholder_content(page: DomPageContext) -> Signal | None:
|
||||
match = PLACEHOLDER_TEXT_PATTERN.search(_text_blob(page))
|
||||
if not match:
|
||||
return None
|
||||
return Signal(
|
||||
"PLACEHOLDER_CONTENT_DOM",
|
||||
"Placeholder or unedited template copy left in the rendered page",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
2.0,
|
||||
0,
|
||||
match.group(0),
|
||||
)
|
||||
|
||||
|
||||
def _detect_placeholder_image_host(page: DomPageContext) -> Signal | None:
|
||||
images = page.dom.get("images", []) or []
|
||||
for image in images:
|
||||
if not isinstance(image, dict):
|
||||
continue
|
||||
src = str(image.get("src", ""))
|
||||
if any(host in src for host in PLACEHOLDER_IMAGE_HOSTS):
|
||||
return Signal(
|
||||
"PLACEHOLDER_IMAGE_HOST",
|
||||
"Image served from a generic placeholder image host",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
1.5,
|
||||
0,
|
||||
src,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_default_framework_title(page: DomPageContext) -> Signal | None:
|
||||
meta = page.dom.get("meta", {}) or {}
|
||||
title = str(meta.get("title", ""))
|
||||
if title == "Vite + React" or "Create Next App" in title:
|
||||
return Signal(
|
||||
"DEFAULT_FRAMEWORK_TITLE",
|
||||
"Unedited default framework document title",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
1.5,
|
||||
0,
|
||||
title,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@dom_check
|
||||
def detect_build_signals(page: DomPageContext) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
for signal in (
|
||||
_detect_tailwind_cdn(page),
|
||||
_detect_cdn_react(page),
|
||||
_detect_react_dev_warning(page),
|
||||
_detect_unhashed_bundle(page),
|
||||
_detect_placeholder_content(page),
|
||||
_detect_placeholder_image_host(page),
|
||||
_detect_default_framework_title(page),
|
||||
):
|
||||
if signal is not None:
|
||||
findings.append(signal)
|
||||
return findings
|
||||
245
devplacepy/services/jobs/isslop/analysis/domsignals/color.py
Normal file
245
devplacepy/services/jobs/isslop/analysis/domsignals/color.py
Normal file
@ -0,0 +1,245 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import colorsys
|
||||
import io
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.base import DomPageContext, dom_check
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_ORIGIN,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_STRONG,
|
||||
SEVERITY_WEAK,
|
||||
Signal,
|
||||
)
|
||||
|
||||
HEX_COLOR_PATTERN = re.compile(r"#[0-9a-fA-F]{6}\b")
|
||||
RGB_COLOR_PATTERN = re.compile(r"rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)")
|
||||
GRADIENT_PATTERN = re.compile(r"linear-gradient\([^)]*\)", re.IGNORECASE)
|
||||
|
||||
TAILWIND_DEFAULT_ACCENTS: frozenset[str] = frozenset(
|
||||
{"#6366f1", "#4f46e5", "#8b5cf6", "#a855f7", "#7c3aed", "#818cf8"}
|
||||
)
|
||||
SIGNATURE_GRADIENT_PATTERN = re.compile(r"#667eea.{0,80}#764ba2|#764ba2.{0,80}#667eea", re.IGNORECASE | re.DOTALL)
|
||||
OKLCH_PATTERN = re.compile(r"oklch\(\s*([\d.]+)", re.IGNORECASE)
|
||||
BOX_SHADOW_PURPLE_PATTERN = re.compile(
|
||||
r"(rgba?\(\s*(?:99|79|139|168|124|129)\s*,\s*\d+\s*,\s*\d+|#(?:6366f1|4f46e5|8b5cf6|a855f7|7c3aed|818cf8))"
|
||||
r"[\s\S]{0,60}?(\d+)px",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
DARK_LUMINANCE_THRESHOLD: int = 40
|
||||
|
||||
|
||||
def _hue_from_hex(hex_code: str) -> float | None:
|
||||
try:
|
||||
r = int(hex_code[1:3], 16) / 255.0
|
||||
g = int(hex_code[3:5], 16) / 255.0
|
||||
b = int(hex_code[5:7], 16) / 255.0
|
||||
except ValueError:
|
||||
return None
|
||||
hue, _lightness, _saturation = colorsys.rgb_to_hls(r, g, b)
|
||||
return hue * 360.0
|
||||
|
||||
|
||||
def _hue_from_rgb(r: int, g: int, b: int) -> float:
|
||||
hue, _lightness, _saturation = colorsys.rgb_to_hls(r / 255.0, g / 255.0, b / 255.0)
|
||||
return hue * 360.0
|
||||
|
||||
|
||||
def _is_blue_or_purple(hue: float) -> bool:
|
||||
return 210.0 <= hue <= 300.0
|
||||
|
||||
|
||||
def _sample_text(sample: dict) -> str:
|
||||
return " ".join(
|
||||
str(sample.get(field, ""))
|
||||
for field in ("backgroundImage", "backgroundColor", "color", "boxShadow")
|
||||
)
|
||||
|
||||
|
||||
def _detect_gradient_hue_combo(samples: list[dict]) -> Signal | None:
|
||||
for sample in samples:
|
||||
gradient = GRADIENT_PATTERN.search(str(sample.get("backgroundImage", "")))
|
||||
if not gradient:
|
||||
continue
|
||||
text = gradient.group(0)
|
||||
hues: list[float] = []
|
||||
for match in HEX_COLOR_PATTERN.finditer(text):
|
||||
hue = _hue_from_hex(match.group(0))
|
||||
if hue is not None:
|
||||
hues.append(hue)
|
||||
for match in RGB_COLOR_PATTERN.finditer(text):
|
||||
hues.append(_hue_from_rgb(int(match.group(1)), int(match.group(2)), int(match.group(3))))
|
||||
if sum(1 for hue in hues if _is_blue_or_purple(hue)) >= 2:
|
||||
return Signal(
|
||||
"SIGNATURE_GRADIENT_HUE_COMBO",
|
||||
"Gradient stops both land in the default LLM blue/purple hue range",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
0,
|
||||
text[:150],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_tailwind_accent(samples: list[dict]) -> Signal | None:
|
||||
for sample in samples:
|
||||
text = _sample_text(sample).lower()
|
||||
for accent in TAILWIND_DEFAULT_ACCENTS:
|
||||
if accent in text:
|
||||
return Signal(
|
||||
"TAILWIND_DEFAULT_ACCENT",
|
||||
"Literal Tailwind default accent color used verbatim",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
0,
|
||||
accent,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_signature_gradient(samples: list[dict]) -> Signal | None:
|
||||
for sample in samples:
|
||||
match = SIGNATURE_GRADIENT_PATTERN.search(_sample_text(sample))
|
||||
if match:
|
||||
return Signal(
|
||||
"SIGNATURE_GRADIENT",
|
||||
"Canonical LLM purple gradient #667eea to #764ba2",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_ORIGIN,
|
||||
3.0,
|
||||
0,
|
||||
match.group(0)[:120],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_oklch_token(samples: list[dict]) -> Signal | None:
|
||||
for sample in samples:
|
||||
match = OKLCH_PATTERN.search(_sample_text(sample))
|
||||
if not match:
|
||||
continue
|
||||
try:
|
||||
lightness = float(match.group(1))
|
||||
except ValueError:
|
||||
continue
|
||||
if 0.55 <= lightness <= 0.75:
|
||||
return Signal(
|
||||
"OKLCH_SHADCN_TOKEN",
|
||||
"oklch() color token in the shadcn/ui default lightness band",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
0,
|
||||
match.group(0),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_purple_glow_shadow(samples: list[dict]) -> Signal | None:
|
||||
for sample in samples:
|
||||
match = BOX_SHADOW_PURPLE_PATTERN.search(str(sample.get("boxShadow", "")))
|
||||
if not match:
|
||||
continue
|
||||
try:
|
||||
blur = int(match.group(2))
|
||||
except ValueError:
|
||||
continue
|
||||
if blur >= 40:
|
||||
return Signal(
|
||||
"PURPLE_GLOW_SHADOW",
|
||||
"Large-blur purple/blue glow box-shadow",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
0,
|
||||
match.group(0)[:120],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_dark_mode_default(samples: list[dict]) -> Signal | None:
|
||||
for sample in samples:
|
||||
if sample.get("tag") != "body":
|
||||
continue
|
||||
match = RGB_COLOR_PATTERN.search(str(sample.get("backgroundColor", "")))
|
||||
if not match:
|
||||
continue
|
||||
channels = [int(match.group(1)), int(match.group(2)), int(match.group(3))]
|
||||
if sum(channels) / 3.0 < DARK_LUMINANCE_THRESHOLD:
|
||||
return Signal(
|
||||
"DARK_MODE_DEFAULT",
|
||||
"Dark background color by default",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_ORIGIN,
|
||||
0.5,
|
||||
0,
|
||||
str(sample.get("backgroundColor", "")),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _screenshot_hue_buckets(screenshot_bytes: bytes) -> dict[int, int]:
|
||||
from PIL import Image
|
||||
|
||||
image = Image.open(io.BytesIO(screenshot_bytes)).convert("RGB")
|
||||
image = image.resize((64, 64))
|
||||
buckets: dict[int, int] = {}
|
||||
for r, g, b in image.getdata():
|
||||
hue, lightness, saturation = colorsys.rgb_to_hls(r / 255.0, g / 255.0, b / 255.0)
|
||||
if saturation < 0.15 or lightness < 0.05 or lightness > 0.95:
|
||||
continue
|
||||
bucket = int((hue * 360.0) // 20) * 20
|
||||
buckets[bucket] = buckets.get(bucket, 0) + 1
|
||||
return buckets
|
||||
|
||||
|
||||
def _detect_screenshot_gradient_hero(screenshot_bytes: bytes | None) -> Signal | None:
|
||||
if not screenshot_bytes:
|
||||
return None
|
||||
try:
|
||||
buckets = _screenshot_hue_buckets(screenshot_bytes)
|
||||
except Exception:
|
||||
return None
|
||||
total = sum(buckets.values())
|
||||
if total <= 0:
|
||||
return None
|
||||
ranked = sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:2]
|
||||
if len(ranked) < 2 or not all(_is_blue_or_purple(bucket) for bucket, _count in ranked):
|
||||
return None
|
||||
coverage = sum(count for _bucket, count in ranked) / total
|
||||
if coverage <= 0.3:
|
||||
return None
|
||||
return Signal(
|
||||
"SCREENSHOT_GRADIENT_HERO",
|
||||
"Screenshot dominated by a blue/purple hero gradient",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
0,
|
||||
f"{coverage:.0%} of sampled pixels in blue/purple hues",
|
||||
)
|
||||
|
||||
|
||||
@dom_check
|
||||
def detect_color_signals(page: DomPageContext) -> list[Signal]:
|
||||
samples = page.dom.get("colors", []) or []
|
||||
findings: list[Signal] = []
|
||||
for detector in (
|
||||
_detect_gradient_hue_combo,
|
||||
_detect_tailwind_accent,
|
||||
_detect_signature_gradient,
|
||||
_detect_oklch_token,
|
||||
_detect_purple_glow_shadow,
|
||||
_detect_dark_mode_default,
|
||||
):
|
||||
signal = detector(samples)
|
||||
if signal is not None:
|
||||
findings.append(signal)
|
||||
screenshot_signal = _detect_screenshot_gradient_hero(page.screenshot_bytes)
|
||||
if screenshot_signal is not None:
|
||||
findings.append(screenshot_signal)
|
||||
return findings
|
||||
157
devplacepy/services/jobs/isslop/analysis/domsignals/copy.py
Normal file
157
devplacepy/services/jobs/isslop/analysis/domsignals/copy.py
Normal file
@ -0,0 +1,157 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.base import DomPageContext, dom_check
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_ORIGIN,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_WEAK,
|
||||
Signal,
|
||||
)
|
||||
|
||||
CLICHE_PHRASES: tuple[str, ...] = (
|
||||
r"unlock (the )?(power|potential) of",
|
||||
r"unleash the power of",
|
||||
r"take your .{1,40} to the next level",
|
||||
r"elevate your",
|
||||
r"seamlessly integrat(e|es|ion)",
|
||||
r"revolutioniz(e|ing) the way",
|
||||
r"whether you('re| are) a .{1,30} or (a )?.{1,30}",
|
||||
r"delve into",
|
||||
r"dive into the world of",
|
||||
r"embark on a journey",
|
||||
r"push(ing)? the boundaries of",
|
||||
r"at the forefront of",
|
||||
r"game[- ]chang(er|ing)",
|
||||
r"pave the way for",
|
||||
r"bridging the gap between",
|
||||
r"navigate the complexities of",
|
||||
r"foster a culture of",
|
||||
r"harness the power of",
|
||||
r"cutting[- ]edge",
|
||||
r"future[- ]proof",
|
||||
r"world[- ]class",
|
||||
r"enterprise[- ]grade",
|
||||
r"streamline(d)?",
|
||||
r"empower(ing)?",
|
||||
r"supercharg(e|ed|ing)",
|
||||
)
|
||||
CLICHE_PATTERN = re.compile("|".join(CLICHE_PHRASES), re.IGNORECASE)
|
||||
|
||||
BUZZWORDS: tuple[str, ...] = ("delve", "crucial", "intricate", "nuanced", "myriad", "realm", "tapestry", "landscape")
|
||||
BUZZWORD_THRESHOLD: int = 3
|
||||
BUZZWORD_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
|
||||
re.compile(rf"\b{word}\b", re.IGNORECASE) for word in BUZZWORDS
|
||||
)
|
||||
|
||||
HEADING_EMOJI_PATTERN = re.compile(r"[\U0001F300-\U0001FAFF✅⭐✨\U0001F680]")
|
||||
|
||||
FAQ_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"^what is\b", re.IGNORECASE),
|
||||
re.compile(r"^how does .{1,30} work\??$", re.IGNORECASE),
|
||||
re.compile(r"^is (it|.{1,20}) secure\??$", re.IGNORECASE),
|
||||
re.compile(r"^can i cancel", re.IGNORECASE),
|
||||
re.compile(r"^do you offer a? ?(free trial|refund)", re.IGNORECASE),
|
||||
)
|
||||
FAQ_PATTERN_THRESHOLD: int = 3
|
||||
|
||||
|
||||
def _heading_texts(page: DomPageContext) -> list[str]:
|
||||
return [
|
||||
str(heading.get("text", ""))
|
||||
for heading in (page.dom.get("headings") or [])
|
||||
if isinstance(heading, dict) and heading.get("text")
|
||||
]
|
||||
|
||||
|
||||
def _text_blob(page: DomPageContext) -> str:
|
||||
parts = list(_heading_texts(page))
|
||||
for image in page.dom.get("images", []) or []:
|
||||
if isinstance(image, dict) and image.get("alt"):
|
||||
parts.append(str(image["alt"]))
|
||||
meta = page.dom.get("meta", {}) or {}
|
||||
description = meta.get("description") if isinstance(meta, dict) else ""
|
||||
if description:
|
||||
parts.append(str(description))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _detect_template_copy(blob: str) -> Signal | None:
|
||||
match = CLICHE_PATTERN.search(blob)
|
||||
if not match:
|
||||
return None
|
||||
return Signal(
|
||||
"TEMPLATE_COPY_DOM",
|
||||
"Stock AI landing-page cliche phrase in rendered copy",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
1.5,
|
||||
0,
|
||||
match.group(0)[:150],
|
||||
)
|
||||
|
||||
|
||||
def _detect_buzzword_cluster(blob: str) -> Signal | None:
|
||||
hits = [pattern.pattern for pattern in BUZZWORD_PATTERNS if pattern.search(blob)]
|
||||
if len(hits) < BUZZWORD_THRESHOLD:
|
||||
return None
|
||||
return Signal(
|
||||
"AI_BUZZWORD_CLUSTER",
|
||||
f"Elevated buzzword cluster ({len(hits)} distinct terms)",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
0,
|
||||
", ".join(hits),
|
||||
)
|
||||
|
||||
|
||||
def _detect_emoji_heading(page: DomPageContext) -> Signal | None:
|
||||
for text in _heading_texts(page):
|
||||
if HEADING_EMOJI_PATTERN.search(text):
|
||||
return Signal(
|
||||
"EMOJI_HEADING_DOM",
|
||||
"Emoji embedded inside a rendered heading",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
0,
|
||||
text[:100],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_generic_faq_template(page: DomPageContext) -> Signal | None:
|
||||
texts = _heading_texts(page)
|
||||
matched = 0
|
||||
for pattern in FAQ_PATTERNS:
|
||||
if any(pattern.search(text.strip()) for text in texts):
|
||||
matched += 1
|
||||
if matched < FAQ_PATTERN_THRESHOLD:
|
||||
return None
|
||||
return Signal(
|
||||
"GENERIC_FAQ_TEMPLATE",
|
||||
f"Generic templated FAQ question set ({matched} canonical patterns matched)",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
1.5,
|
||||
0,
|
||||
f"{matched} FAQ patterns matched",
|
||||
)
|
||||
|
||||
|
||||
@dom_check
|
||||
def detect_copy_signals(page: DomPageContext) -> list[Signal]:
|
||||
blob = _text_blob(page)
|
||||
findings: list[Signal] = []
|
||||
for signal in (
|
||||
_detect_template_copy(blob),
|
||||
_detect_buzzword_cluster(blob),
|
||||
_detect_emoji_heading(page),
|
||||
_detect_generic_faq_template(page),
|
||||
):
|
||||
if signal is not None:
|
||||
findings.append(signal)
|
||||
return findings
|
||||
104
devplacepy/services/jobs/isslop/analysis/domsignals/layout.py
Normal file
104
devplacepy/services/jobs/isslop/analysis/domsignals/layout.py
Normal file
@ -0,0 +1,104 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.base import DomPageContext, dom_check
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_ORIGIN,
|
||||
AXIS_QUALITY,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_WEAK,
|
||||
Signal,
|
||||
)
|
||||
|
||||
LANDING_SECTION_TOKENS: tuple[str, ...] = ("hero", "features", "testimonials", "pricing", "faq", "cta")
|
||||
LANDING_SECTION_THRESHOLD: int = 4
|
||||
DEAD_ANCHOR_THRESHOLD: int = 5
|
||||
REPEATED_CARD_THRESHOLD: int = 3
|
||||
|
||||
|
||||
def _detect_landing_template(class_names: dict) -> Signal | None:
|
||||
matched = {
|
||||
token
|
||||
for token in LANDING_SECTION_TOKENS
|
||||
if any(token in name.lower() for name in class_names)
|
||||
}
|
||||
if len(matched) < LANDING_SECTION_THRESHOLD:
|
||||
return None
|
||||
return Signal(
|
||||
"LANDING_TEMPLATE_DOM",
|
||||
"Canonical hero-features-testimonials-pricing landing structure in the rendered DOM",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
0,
|
||||
", ".join(sorted(matched)),
|
||||
)
|
||||
|
||||
|
||||
def _detect_glassmorphic_navbar(samples: list[dict]) -> Signal | None:
|
||||
for sample in samples:
|
||||
tag = str(sample.get("tag", ""))
|
||||
if tag not in ("nav", "header"):
|
||||
continue
|
||||
blob = " ".join(
|
||||
str(sample.get(field, "")) for field in ("backgroundImage", "backgroundColor", "boxShadow")
|
||||
).lower()
|
||||
if "blur(" not in blob:
|
||||
continue
|
||||
if "rgba(" in blob or "hsla(" in blob:
|
||||
return Signal(
|
||||
"GLASSMORPHIC_NAVBAR",
|
||||
"Glassmorphic translucent-blur navigation bar",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
0,
|
||||
blob[:150],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_repeated_card_class(class_names: dict) -> Signal | None:
|
||||
for name, count in class_names.items():
|
||||
if count >= REPEATED_CARD_THRESHOLD:
|
||||
return Signal(
|
||||
"REPEATED_CARD_CLASS",
|
||||
f"Class token repeated across {count} elements, a generated card-grid shape",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
0,
|
||||
f"{name} x{count}",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_dead_anchor_links(dead_anchor_count: int) -> Signal | None:
|
||||
if dead_anchor_count < DEAD_ANCHOR_THRESHOLD:
|
||||
return None
|
||||
return Signal(
|
||||
"DEAD_ANCHOR_LINKS_DOM",
|
||||
f'{dead_anchor_count} rendered links pointing to href="#"',
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
0,
|
||||
f"{dead_anchor_count} placeholder links",
|
||||
)
|
||||
|
||||
|
||||
@dom_check
|
||||
def detect_layout_signals(page: DomPageContext) -> list[Signal]:
|
||||
class_names = page.dom.get("classNames", {}) or {}
|
||||
colors = page.dom.get("colors", []) or []
|
||||
dead_anchor_count = int(page.dom.get("deadAnchorCount", 0) or 0)
|
||||
findings: list[Signal] = []
|
||||
for signal in (
|
||||
_detect_landing_template(class_names),
|
||||
_detect_glassmorphic_navbar(colors),
|
||||
_detect_repeated_card_class(class_names),
|
||||
_detect_dead_anchor_links(dead_anchor_count),
|
||||
):
|
||||
if signal is not None:
|
||||
findings.append(signal)
|
||||
return findings
|
||||
@ -0,0 +1,66 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.base import DomPageContext, DomSiteContext, dom_check, dom_site_check
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import AXIS_QUALITY, SEVERITY_MEDIUM, SEVERITY_WEAK, Signal
|
||||
|
||||
|
||||
def _meta(page: DomPageContext) -> dict:
|
||||
meta = page.dom.get("meta", {})
|
||||
return meta if isinstance(meta, dict) else {}
|
||||
|
||||
|
||||
@dom_check
|
||||
def detect_metaseo_signals(page: DomPageContext) -> list[Signal]:
|
||||
meta = _meta(page)
|
||||
findings: list[Signal] = []
|
||||
if not str(meta.get("description") or "").strip():
|
||||
findings.append(
|
||||
Signal("MISSING_META_DESCRIPTION", "No meta description present", SEVERITY_WEAK, AXIS_QUALITY, 1.0, 0, "")
|
||||
)
|
||||
if int(page.dom.get("jsonld", 0) or 0) == 0:
|
||||
findings.append(
|
||||
Signal("MISSING_STRUCTURED_DATA", "No JSON-LD structured data present", SEVERITY_WEAK, AXIS_QUALITY, 0.5, 0, "")
|
||||
)
|
||||
if not str(meta.get("ogImage") or "").strip():
|
||||
findings.append(
|
||||
Signal("MISSING_OG_IMAGE", "No og:image meta tag present", SEVERITY_WEAK, AXIS_QUALITY, 0.5, 0, "")
|
||||
)
|
||||
if not meta.get("favicon"):
|
||||
findings.append(
|
||||
Signal("MISSING_FAVICON", "No favicon link present", SEVERITY_WEAK, AXIS_QUALITY, 0.5, 0, "")
|
||||
)
|
||||
if not str(meta.get("htmlLang") or "").strip():
|
||||
findings.append(
|
||||
Signal("MISSING_HTML_LANG", "No html lang attribute present", SEVERITY_WEAK, AXIS_QUALITY, 0.5, 0, "")
|
||||
)
|
||||
if not str(meta.get("canonical") or "").strip():
|
||||
findings.append(
|
||||
Signal("MISSING_CANONICAL", "No canonical link present", SEVERITY_WEAK, AXIS_QUALITY, 0.5, 0, "")
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
@dom_site_check
|
||||
def detect_duplicate_meta_description(site: DomSiteContext) -> list[Signal]:
|
||||
if len(site.pages) < 2:
|
||||
return []
|
||||
counts: dict[str, int] = {}
|
||||
for page in site.pages:
|
||||
description = str(_meta(page).get("description") or "").strip()
|
||||
if description:
|
||||
counts[description] = counts.get(description, 0) + 1
|
||||
for description, count in counts.items():
|
||||
if count >= 2:
|
||||
return [
|
||||
Signal(
|
||||
"DUPLICATE_META_DESCRIPTION",
|
||||
"Identical meta description reused across multiple pages",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
1.5,
|
||||
0,
|
||||
description[:150],
|
||||
)
|
||||
]
|
||||
return []
|
||||
@ -0,0 +1,35 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.base import (
|
||||
DOM_CHECKS,
|
||||
DOM_SITE_CHECKS,
|
||||
DomPageContext,
|
||||
DomSiteContext,
|
||||
)
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import Signal
|
||||
from . import accessibility, builders, buildsignals, color, copy, layout, metaseo, typography
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_dom_checks(page: DomPageContext) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
for func in DOM_CHECKS:
|
||||
try:
|
||||
findings.extend(func(page))
|
||||
except Exception as error:
|
||||
logger.warning("DOM check %s failed for %s: %s", func.__name__, page.url, error)
|
||||
return findings
|
||||
|
||||
|
||||
def run_dom_site_checks(site: DomSiteContext) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
for func in DOM_SITE_CHECKS:
|
||||
try:
|
||||
findings.extend(func(site))
|
||||
except Exception as error:
|
||||
logger.warning("DOM site check %s failed: %s", func.__name__, error)
|
||||
return findings
|
||||
@ -0,0 +1,128 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.base import DomPageContext, dom_check
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_ORIGIN,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_WEAK,
|
||||
Signal,
|
||||
)
|
||||
|
||||
DEFAULT_AI_FONTS: tuple[str, ...] = (
|
||||
"Inter",
|
||||
"Poppins",
|
||||
"Manrope",
|
||||
"Geist",
|
||||
"Space Grotesk",
|
||||
"DM Sans",
|
||||
"Plus Jakarta Sans",
|
||||
)
|
||||
DECORATIVE_MONOSPACE_FONTS: tuple[str, ...] = ("JetBrains Mono", "Space Mono")
|
||||
GOOGLE_FONTS_WEIGHTSET_PATTERN = re.compile(
|
||||
r"fonts\.googleapis\.com.*family=(Inter|Poppins|Manrope)[^&]*wght@400;500;600;700",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _detect_default_font(fonts: list[dict]) -> Signal | None:
|
||||
for sample in fonts:
|
||||
family = str(sample.get("fontFamily", ""))
|
||||
for candidate in DEFAULT_AI_FONTS:
|
||||
if candidate.lower() in family.lower():
|
||||
return Signal(
|
||||
"DEFAULT_AI_FONT",
|
||||
f"Default AI-tool font family in use ({candidate})",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
0,
|
||||
family[:100],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_instrument_serif(fonts: list[dict]) -> Signal | None:
|
||||
for sample in fonts:
|
||||
family = str(sample.get("fontFamily", ""))
|
||||
if "instrument serif" in family.lower():
|
||||
return Signal(
|
||||
"INSTRUMENT_SERIF_ACCENT",
|
||||
"Instrument Serif accent font pairing",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
0,
|
||||
family[:100],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_single_font_family(fonts: list[dict]) -> Signal | None:
|
||||
families = {str(sample.get("fontFamily", "")).strip() for sample in fonts if sample.get("fontFamily")}
|
||||
if len(fonts) >= 2 and len(families) == 1:
|
||||
return Signal(
|
||||
"SINGLE_FONT_FAMILY",
|
||||
"Every sampled element shares one exact font-family declaration",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_ORIGIN,
|
||||
0.5,
|
||||
0,
|
||||
next(iter(families))[:100],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_google_fonts_weightset(links: list[str]) -> Signal | None:
|
||||
for href in links:
|
||||
match = GOOGLE_FONTS_WEIGHTSET_PATTERN.search(str(href))
|
||||
if match:
|
||||
return Signal(
|
||||
"GOOGLE_FONTS_DEFAULT_WEIGHTSET",
|
||||
"Google Fonts request for the canonical AI-tool weight set (400;500;600;700)",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
1.5,
|
||||
0,
|
||||
str(href)[:150],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_decorative_monospace(fonts: list[dict]) -> Signal | None:
|
||||
for sample in fonts:
|
||||
tag = str(sample.get("tag", ""))
|
||||
if tag not in ("h1", "h2", "h3", "button"):
|
||||
continue
|
||||
family = str(sample.get("fontFamily", ""))
|
||||
for candidate in DECORATIVE_MONOSPACE_FONTS:
|
||||
if candidate.lower() in family.lower():
|
||||
return Signal(
|
||||
"DECORATIVE_MONOSPACE",
|
||||
f"Decorative monospace font used outside code ({candidate})",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_ORIGIN,
|
||||
0.5,
|
||||
0,
|
||||
family[:100],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@dom_check
|
||||
def detect_typography_signals(page: DomPageContext) -> list[Signal]:
|
||||
fonts = page.dom.get("fonts", []) or []
|
||||
links = page.dom.get("links", []) or []
|
||||
findings: list[Signal] = []
|
||||
for signal in (
|
||||
_detect_default_font(fonts),
|
||||
_detect_instrument_serif(fonts),
|
||||
_detect_single_font_family(fonts),
|
||||
_detect_google_fonts_weightset(links),
|
||||
_detect_decorative_monospace(fonts),
|
||||
):
|
||||
if signal is not None:
|
||||
findings.append(signal)
|
||||
return findings
|
||||
323
devplacepy/services/jobs/isslop/analysis/engine.py
Normal file
323
devplacepy/services/jobs/isslop/analysis/engine.py
Normal file
@ -0,0 +1,323 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.baselines import RepoBaselines, compute_baselines, deviation_signals
|
||||
from devplacepy.services.jobs.isslop.analysis.exclusion import exclusion_reason, is_excluded_directory, looks_minified
|
||||
from devplacepy.services.jobs.isslop.analysis.languages import CODE_LANGUAGES, DOCUMENT_LANGUAGES, extract_comments, language_for
|
||||
from devplacepy.services.jobs.isslop.analysis.metrics import FileMetrics, compute_metrics
|
||||
from devplacepy.services.jobs.isslop.analysis.scoring import (
|
||||
FileScore,
|
||||
categorize,
|
||||
criticality_for,
|
||||
fingerprint_origin_score,
|
||||
origin_score,
|
||||
quality_deficit_score,
|
||||
)
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import Detector, FileContext, RepoContext, run_detectors
|
||||
from devplacepy.services.jobs.isslop.analysis.signals.documentation import detect_documentation
|
||||
from devplacepy.services.jobs.isslop.analysis.signals.errors import detect_error_handling
|
||||
from devplacepy.services.jobs.isslop.analysis.signals.hallucination import detect_hallucination
|
||||
from devplacepy.services.jobs.isslop.analysis.signals.infrastructure import detect_infrastructure
|
||||
from devplacepy.services.jobs.isslop.analysis.signals.language import detect_language_tells
|
||||
from devplacepy.services.jobs.isslop.analysis.signals.llmdefaults import detect_llm_defaults
|
||||
from devplacepy.services.jobs.isslop.analysis.signals.naming import detect_naming
|
||||
from devplacepy.services.jobs.isslop.analysis.signals.scaffolds import detect_scaffolds
|
||||
from devplacepy.services.jobs.isslop.analysis.signals.security import detect_security
|
||||
from devplacepy.services.jobs.isslop.analysis.signals.structure import detect_structure
|
||||
from devplacepy.services.jobs.isslop.analysis.signals.textual import detect_textual
|
||||
from devplacepy.services.jobs.isslop.analysis.signals.vibeerrors import detect_vibe_errors
|
||||
from devplacepy.services.jobs.isslop.analysis.signals.webtells import detect_web_tells
|
||||
from devplacepy.services.jobs.isslop.config import ANALYSIS_MAX_FILES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DETECTORS: list[Detector] = [
|
||||
detect_textual,
|
||||
detect_naming,
|
||||
detect_structure,
|
||||
detect_hallucination,
|
||||
detect_error_handling,
|
||||
detect_security,
|
||||
detect_documentation,
|
||||
detect_language_tells,
|
||||
detect_web_tells,
|
||||
detect_llm_defaults,
|
||||
detect_infrastructure,
|
||||
detect_vibe_errors,
|
||||
detect_scaffolds,
|
||||
]
|
||||
|
||||
FINGERPRINT_DETECTORS: list[Detector] = [
|
||||
detect_textual,
|
||||
detect_web_tells,
|
||||
detect_llm_defaults,
|
||||
detect_infrastructure,
|
||||
detect_security,
|
||||
detect_vibe_errors,
|
||||
detect_scaffolds,
|
||||
]
|
||||
|
||||
PYPROJECT_DEPENDENCY_PATTERN = re.compile(r"[\"']([A-Za-z0-9._-]+)\s*(?:[><=!~\[;].*)?[\"']")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InventoryEntry:
|
||||
path: Path
|
||||
relative: str
|
||||
language: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Inventory:
|
||||
analyzable: list[InventoryEntry]
|
||||
excluded: list[tuple[str, str]]
|
||||
repo: RepoContext
|
||||
|
||||
|
||||
def _python_dependencies(root: Path) -> tuple[frozenset[str], bool]:
|
||||
dependencies: set[str] = set()
|
||||
found = False
|
||||
for name in ("requirements.txt", "requirements-dev.txt"):
|
||||
manifest = root / name
|
||||
if manifest.exists():
|
||||
found = True
|
||||
for line in manifest.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
cleaned = line.split("#")[0].strip()
|
||||
if cleaned and not cleaned.startswith("-"):
|
||||
dependencies.add(re.split(r"[><=!~\[;@ ]", cleaned)[0])
|
||||
pyproject = root / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
text = pyproject.read_text(encoding="utf-8", errors="replace")
|
||||
if "dependencies" in text or "[tool.poetry" in text:
|
||||
found = True
|
||||
block = re.search(r"dependencies\s*=\s*\[(.*?)\]", text, re.DOTALL)
|
||||
if block:
|
||||
for match in PYPROJECT_DEPENDENCY_PATTERN.finditer(block.group(1)):
|
||||
dependencies.add(match.group(1))
|
||||
poetry = re.search(r"\[tool\.poetry\.dependencies\](.*?)(?:\n\[|\Z)", text, re.DOTALL)
|
||||
if poetry:
|
||||
for line in poetry.group(1).splitlines():
|
||||
key = line.split("=")[0].strip().strip('"')
|
||||
if key and key != "python":
|
||||
dependencies.add(key)
|
||||
project_name = re.search(r"^\s*name\s*=\s*[\"']([\w.-]+)[\"']", text, re.MULTILINE)
|
||||
if project_name:
|
||||
dependencies.add(project_name.group(1))
|
||||
return frozenset(dependencies), found
|
||||
|
||||
|
||||
def _javascript_dependencies(root: Path) -> tuple[frozenset[str], bool]:
|
||||
manifest = root / "package.json"
|
||||
if not manifest.exists():
|
||||
return frozenset(), False
|
||||
try:
|
||||
body = json.loads(manifest.read_text(encoding="utf-8", errors="replace"))
|
||||
except (json.JSONDecodeError, OSError) as error:
|
||||
logger.warning("package.json unreadable: %s", error)
|
||||
return frozenset(), False
|
||||
dependencies: set[str] = set()
|
||||
for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
|
||||
section = body.get(key)
|
||||
if isinstance(section, dict):
|
||||
dependencies.update(section.keys())
|
||||
name = body.get("name")
|
||||
if isinstance(name, str):
|
||||
dependencies.add(name)
|
||||
return frozenset(dependencies), True
|
||||
|
||||
|
||||
def _local_python_modules(root: Path) -> frozenset[str]:
|
||||
modules: set[str] = set()
|
||||
for entry in root.iterdir():
|
||||
if entry.is_dir() and not is_excluded_directory(entry.name):
|
||||
modules.add(entry.name)
|
||||
if (entry / "__init__.py").exists():
|
||||
modules.add(entry.name)
|
||||
elif entry.suffix == ".py":
|
||||
modules.add(entry.stem)
|
||||
for src in (root / "src", root / "lib"):
|
||||
if src.is_dir():
|
||||
for entry in src.iterdir():
|
||||
if entry.is_dir() or entry.suffix == ".py":
|
||||
modules.add(entry.stem if entry.is_file() else entry.name)
|
||||
return frozenset(modules)
|
||||
|
||||
|
||||
JSONC_TRAILING_COMMA_PATTERN = re.compile(r",(\s*[}\]])")
|
||||
|
||||
|
||||
def _strip_jsonc_comments(text: str) -> str:
|
||||
out: list[str] = []
|
||||
position = 0
|
||||
length = len(text)
|
||||
in_string = False
|
||||
escaped = False
|
||||
while position < length:
|
||||
char = text[position]
|
||||
if in_string:
|
||||
out.append(char)
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char == '"':
|
||||
in_string = False
|
||||
position += 1
|
||||
continue
|
||||
if char == '"':
|
||||
in_string = True
|
||||
out.append(char)
|
||||
position += 1
|
||||
continue
|
||||
if char == "/" and text[position + 1 : position + 2] == "/":
|
||||
while position < length and text[position] != "\n":
|
||||
position += 1
|
||||
continue
|
||||
if char == "/" and text[position + 1 : position + 2] == "*":
|
||||
position += 2
|
||||
while position + 1 < length and not (text[position] == "*" and text[position + 1] == "/"):
|
||||
position += 1
|
||||
position += 2
|
||||
continue
|
||||
out.append(char)
|
||||
position += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _parse_jsonc(text: str) -> dict:
|
||||
cleaned = _strip_jsonc_comments(text)
|
||||
cleaned = JSONC_TRAILING_COMMA_PATTERN.sub(r"\1", cleaned)
|
||||
parsed = json.loads(cleaned)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _javascript_alias_prefixes(root: Path) -> frozenset[str]:
|
||||
prefixes: set[str] = set()
|
||||
for name in ("tsconfig.json", "jsconfig.json"):
|
||||
manifest = root / name
|
||||
if not manifest.exists():
|
||||
continue
|
||||
try:
|
||||
body = _parse_jsonc(manifest.read_text(encoding="utf-8", errors="replace"))
|
||||
except (json.JSONDecodeError, OSError) as error:
|
||||
logger.warning("%s unreadable: %s", name, error)
|
||||
continue
|
||||
options = body.get("compilerOptions")
|
||||
paths = options.get("paths") if isinstance(options, dict) else None
|
||||
if not isinstance(paths, dict):
|
||||
continue
|
||||
for alias in paths:
|
||||
prefix = str(alias).rstrip("*")
|
||||
if prefix:
|
||||
prefixes.add(prefix)
|
||||
return frozenset(prefixes)
|
||||
|
||||
|
||||
def build_repo_context(root: Path) -> RepoContext:
|
||||
python_deps, has_python = _python_dependencies(root)
|
||||
javascript_deps, has_javascript = _javascript_dependencies(root)
|
||||
return RepoContext(
|
||||
root=root,
|
||||
python_dependencies=python_deps,
|
||||
javascript_dependencies=javascript_deps,
|
||||
local_python_modules=_local_python_modules(root),
|
||||
has_python_manifest=has_python,
|
||||
has_javascript_manifest=has_javascript,
|
||||
javascript_alias_prefixes=_javascript_alias_prefixes(root),
|
||||
)
|
||||
|
||||
|
||||
def build_inventory(workspace: Path) -> Inventory:
|
||||
analyzable: list[InventoryEntry] = []
|
||||
excluded: list[tuple[str, str]] = []
|
||||
candidates = sorted(
|
||||
entry for entry in workspace.rglob("*") if entry.is_file() and not entry.is_symlink()
|
||||
)
|
||||
for path in candidates:
|
||||
relative = str(path.relative_to(workspace))
|
||||
reason = exclusion_reason(path, workspace)
|
||||
if reason:
|
||||
excluded.append((relative, reason))
|
||||
continue
|
||||
language = language_for(path.name)
|
||||
if language not in CODE_LANGUAGES and language not in DOCUMENT_LANGUAGES:
|
||||
excluded.append((relative, f"unsupported language ({language})"))
|
||||
continue
|
||||
analyzable.append(InventoryEntry(path=path, relative=relative, language=language))
|
||||
if len(analyzable) >= ANALYSIS_MAX_FILES:
|
||||
logger.warning("File cap of %d reached, remaining files skipped", ANALYSIS_MAX_FILES)
|
||||
break
|
||||
repo = build_repo_context(workspace)
|
||||
logger.info("Inventory: %d analyzable, %d excluded", len(analyzable), len(excluded))
|
||||
return Inventory(analyzable=analyzable, excluded=excluded, repo=repo)
|
||||
|
||||
|
||||
def load_context(entry: InventoryEntry, repo: RepoContext) -> FileContext | None:
|
||||
try:
|
||||
text = entry.path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as error:
|
||||
logger.warning("Read failed for %s: %s", entry.relative, error)
|
||||
return None
|
||||
fingerprint_only = looks_minified(text)
|
||||
lines = text.splitlines()
|
||||
context = FileContext(
|
||||
path=entry.path,
|
||||
relative=entry.relative,
|
||||
language=entry.language,
|
||||
text=text,
|
||||
lines=lines,
|
||||
comments=extract_comments(entry.language, lines),
|
||||
repo=repo,
|
||||
fingerprint_only=fingerprint_only,
|
||||
)
|
||||
context.metrics = compute_metrics(entry.language, text)
|
||||
return context
|
||||
|
||||
|
||||
def score_file(context: FileContext, baselines: RepoBaselines) -> FileScore:
|
||||
metrics = context.metrics
|
||||
if metrics is None:
|
||||
metrics = compute_metrics(context.language, context.text)
|
||||
context.metrics = metrics
|
||||
if context.fingerprint_only:
|
||||
signals = run_detectors(context, FINGERPRINT_DETECTORS)
|
||||
origin = fingerprint_origin_score(signals)
|
||||
quality = quality_deficit_score(signals, metrics)
|
||||
effective_sloc = max(metrics.sloc, min(metrics.total_lines, 20))
|
||||
return FileScore(
|
||||
relative=context.relative,
|
||||
language=context.language,
|
||||
sloc=effective_sloc,
|
||||
origin_score=origin,
|
||||
quality_deficit=quality,
|
||||
category=categorize(origin, quality),
|
||||
criticality=criticality_for(context.relative),
|
||||
signals=signals,
|
||||
)
|
||||
signals = run_detectors(context, DETECTORS)
|
||||
signals.extend(deviation_signals(context.relative, metrics, baselines))
|
||||
origin = origin_score(context.text, metrics, signals)
|
||||
quality = quality_deficit_score(signals, metrics)
|
||||
return FileScore(
|
||||
relative=context.relative,
|
||||
language=context.language,
|
||||
sloc=metrics.sloc,
|
||||
origin_score=origin,
|
||||
quality_deficit=quality,
|
||||
category=categorize(origin, quality),
|
||||
criticality=criticality_for(context.relative),
|
||||
signals=signals,
|
||||
)
|
||||
|
||||
|
||||
def compute_repo_baselines(contexts: list[FileContext]) -> RepoBaselines:
|
||||
metrics_by_file: dict[str, FileMetrics] = {
|
||||
context.relative: context.metrics for context in contexts if context.metrics is not None
|
||||
}
|
||||
return compute_baselines(metrics_by_file)
|
||||
142
devplacepy/services/jobs/isslop/analysis/exclusion.py
Normal file
142
devplacepy/services/jobs/isslop/analysis/exclusion.py
Normal file
@ -0,0 +1,142 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.services.jobs.isslop.config import ANALYSIS_FILE_CAP_BYTES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXCLUDED_DIRECTORIES: frozenset[str] = frozenset(
|
||||
{
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
"node_modules",
|
||||
"bower_components",
|
||||
"vendor",
|
||||
"third_party",
|
||||
"thirdparty",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
"env",
|
||||
".env",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
"target",
|
||||
"coverage",
|
||||
".next",
|
||||
".nuxt",
|
||||
".cache",
|
||||
"site-packages",
|
||||
".idea",
|
||||
".vscode",
|
||||
".tox",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".terraform",
|
||||
"migrations",
|
||||
}
|
||||
)
|
||||
|
||||
LOCKFILE_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"poetry.lock",
|
||||
"pipfile.lock",
|
||||
"gemfile.lock",
|
||||
"go.sum",
|
||||
"cargo.lock",
|
||||
"composer.lock",
|
||||
"uv.lock",
|
||||
"bun.lockb",
|
||||
}
|
||||
)
|
||||
|
||||
GENERATED_SUFFIXES: tuple[str, ...] = (
|
||||
".min.js",
|
||||
".min.css",
|
||||
".map",
|
||||
".pb.go",
|
||||
"_pb2.py",
|
||||
"_pb2_grpc.py",
|
||||
".bundle.js",
|
||||
".chunk.js",
|
||||
".d.ts",
|
||||
)
|
||||
|
||||
BINARY_EXTENSIONS: frozenset[str] = frozenset(
|
||||
{
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".bmp", ".ico", ".tiff", ".svgz",
|
||||
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
||||
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
|
||||
".mp3", ".mp4", ".wav", ".ogg", ".webm", ".avi", ".mov", ".flac",
|
||||
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
||||
".exe", ".dll", ".so", ".dylib", ".bin", ".dat", ".db", ".sqlite", ".sqlite3",
|
||||
".pyc", ".pyo", ".class", ".jar", ".war", ".wasm", ".o", ".a",
|
||||
}
|
||||
)
|
||||
|
||||
GENERATED_MARKERS: tuple[str, ...] = (
|
||||
"do not edit",
|
||||
"auto-generated",
|
||||
"autogenerated",
|
||||
"@generated",
|
||||
"code generated by",
|
||||
"this file was generated",
|
||||
)
|
||||
|
||||
MINIFIED_AVG_LINE_LENGTH: int = 300
|
||||
|
||||
|
||||
def is_excluded_directory(part: str) -> bool:
|
||||
return part.lower() in EXCLUDED_DIRECTORIES
|
||||
|
||||
|
||||
def exclusion_reason(path: Path, workspace: Path) -> Optional[str]:
|
||||
relative = path.relative_to(workspace)
|
||||
for part in relative.parts[:-1]:
|
||||
if is_excluded_directory(part):
|
||||
return f"vendored or generated directory: {part}"
|
||||
name = path.name.lower()
|
||||
if name in LOCKFILE_NAMES:
|
||||
return "lockfile"
|
||||
for suffix in GENERATED_SUFFIXES:
|
||||
if name.endswith(suffix):
|
||||
return f"generated artifact ({suffix})"
|
||||
if path.suffix.lower() in BINARY_EXTENSIONS:
|
||||
return "binary file"
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError as error:
|
||||
return f"unreadable: {error}"
|
||||
if size == 0:
|
||||
return "empty file"
|
||||
if size > ANALYSIS_FILE_CAP_BYTES:
|
||||
return f"oversize ({size} bytes)"
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
head = handle.read(8192)
|
||||
except OSError as error:
|
||||
return f"unreadable: {error}"
|
||||
if b"\x00" in head:
|
||||
return "binary content"
|
||||
text_head = head.decode("utf-8", errors="replace").lower()
|
||||
for marker in GENERATED_MARKERS:
|
||||
if marker in text_head[:600]:
|
||||
return f"generated marker: {marker}"
|
||||
return None
|
||||
|
||||
|
||||
def looks_minified(text: str) -> bool:
|
||||
lines = [line for line in text.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return False
|
||||
average = sum(len(line) for line in lines) / len(lines)
|
||||
return average > MINIFIED_AVG_LINE_LENGTH
|
||||
135
devplacepy/services/jobs/isslop/analysis/languages.py
Normal file
135
devplacepy/services/jobs/isslop/analysis/languages.py
Normal file
@ -0,0 +1,135 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
LANGUAGE_BY_EXTENSION: dict[str, str] = {
|
||||
".py": "python",
|
||||
".pyw": "python",
|
||||
".js": "javascript",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".jsx": "javascript",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescript",
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
".css": "css",
|
||||
".scss": "css",
|
||||
".less": "css",
|
||||
".php": "php",
|
||||
".go": "go",
|
||||
".java": "java",
|
||||
".kt": "java",
|
||||
".c": "c",
|
||||
".h": "c",
|
||||
".cpp": "cpp",
|
||||
".cc": "cpp",
|
||||
".hpp": "cpp",
|
||||
".cs": "csharp",
|
||||
".rb": "ruby",
|
||||
".rs": "rust",
|
||||
".sh": "shell",
|
||||
".bash": "shell",
|
||||
".lua": "lua",
|
||||
".sql": "sql",
|
||||
".md": "markdown",
|
||||
".rst": "markdown",
|
||||
".txt": "text",
|
||||
".json": "json",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".toml": "toml",
|
||||
".xml": "xml",
|
||||
".vue": "javascript",
|
||||
".svelte": "javascript",
|
||||
}
|
||||
|
||||
CODE_LANGUAGES: frozenset[str] = frozenset(
|
||||
{
|
||||
"python",
|
||||
"javascript",
|
||||
"typescript",
|
||||
"php",
|
||||
"go",
|
||||
"java",
|
||||
"c",
|
||||
"cpp",
|
||||
"csharp",
|
||||
"ruby",
|
||||
"rust",
|
||||
"shell",
|
||||
"lua",
|
||||
"sql",
|
||||
"html",
|
||||
"css",
|
||||
}
|
||||
)
|
||||
|
||||
DOCUMENT_LANGUAGES: frozenset[str] = frozenset({"markdown", "text"})
|
||||
|
||||
LINE_COMMENT_PREFIXES: dict[str, tuple[str, ...]] = {
|
||||
"python": ("#",),
|
||||
"shell": ("#",),
|
||||
"ruby": ("#",),
|
||||
"yaml": ("#",),
|
||||
"toml": ("#",),
|
||||
"javascript": ("//",),
|
||||
"typescript": ("//",),
|
||||
"php": ("//", "#"),
|
||||
"go": ("//",),
|
||||
"java": ("//",),
|
||||
"c": ("//",),
|
||||
"cpp": ("//",),
|
||||
"csharp": ("//",),
|
||||
"rust": ("//",),
|
||||
"sql": ("--",),
|
||||
"lua": ("--",),
|
||||
}
|
||||
|
||||
BLOCK_COMMENT_LANGUAGES: frozenset[str] = frozenset(
|
||||
{"javascript", "typescript", "php", "go", "java", "c", "cpp", "csharp", "rust", "css"}
|
||||
)
|
||||
|
||||
|
||||
def language_for(filename: str) -> str:
|
||||
lowered = filename.lower()
|
||||
for extension, language in LANGUAGE_BY_EXTENSION.items():
|
||||
if lowered.endswith(extension):
|
||||
return language
|
||||
return "unknown"
|
||||
|
||||
|
||||
def extract_comments(language: str, lines: list[str]) -> list[tuple[int, str]]:
|
||||
comments: list[tuple[int, str]] = []
|
||||
prefixes = LINE_COMMENT_PREFIXES.get(language, ())
|
||||
in_block = False
|
||||
block_open, block_close = ("/*", "*/") if language in BLOCK_COMMENT_LANGUAGES or language == "css" else ("", "")
|
||||
if language == "html":
|
||||
block_open, block_close = "<!--", "-->"
|
||||
for number, raw in enumerate(lines, start=1):
|
||||
stripped = raw.strip()
|
||||
if in_block:
|
||||
comments.append((number, stripped.replace(block_close, "").strip()))
|
||||
if block_close and block_close in stripped:
|
||||
in_block = False
|
||||
continue
|
||||
if block_open and block_open in stripped:
|
||||
fragment = stripped.split(block_open, 1)[1]
|
||||
comments.append((number, fragment.replace(block_close, "").strip()))
|
||||
if block_close not in fragment:
|
||||
in_block = True
|
||||
continue
|
||||
for prefix in prefixes:
|
||||
if stripped.startswith(prefix):
|
||||
comments.append((number, stripped[len(prefix):].strip()))
|
||||
break
|
||||
marker = f" {prefix}"
|
||||
if marker in raw and not _inside_string(raw, raw.index(marker)):
|
||||
comments.append((number, raw.split(marker, 1)[1].strip()))
|
||||
break
|
||||
return comments
|
||||
|
||||
|
||||
def _inside_string(line: str, position: int) -> bool:
|
||||
double = line.count('"', 0, position) % 2 == 1
|
||||
single = line.count("'", 0, position) % 2 == 1
|
||||
return double or single
|
||||
163
devplacepy/services/jobs/isslop/analysis/metrics.py
Normal file
163
devplacepy/services/jobs/isslop/analysis/metrics.py
Normal file
@ -0,0 +1,163 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.languages import extract_comments
|
||||
|
||||
COMPLEXITY_TOKENS = re.compile(
|
||||
r"\b(if|elif|else if|for|while|case|when|catch|except|and|or)\b|&&|\|\||\?\s"
|
||||
)
|
||||
FUNCTION_PATTERNS: dict[str, re.Pattern[str]] = {
|
||||
"python": re.compile(r"^\s*(?:async\s+)?def\s+(\w+)", re.MULTILINE),
|
||||
"javascript": re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)|^\s*(\w+)\s*\([^)]*\)\s*{", re.MULTILINE),
|
||||
"typescript": re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)|^\s*(\w+)\s*\([^)]*\)\s*{", re.MULTILINE),
|
||||
"go": re.compile(r"^\s*func\s+(?:\([^)]+\)\s*)?(\w+)", re.MULTILINE),
|
||||
"java": re.compile(r"^\s*(?:public|private|protected|static|\s)+[\w<>\[\]]+\s+(\w+)\s*\(", re.MULTILINE),
|
||||
"php": re.compile(r"^\s*(?:public|private|protected|static|\s)*function\s+(\w+)", re.MULTILINE),
|
||||
"ruby": re.compile(r"^\s*def\s+(\w+)", re.MULTILINE),
|
||||
"rust": re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)", re.MULTILINE),
|
||||
}
|
||||
DOCSTRING_PATTERN = re.compile(r"def\s+\w+[^:]*:\s*\n\s*(?:\"\"\"|''')", re.MULTILINE)
|
||||
TOKEN_PATTERN = re.compile(r"[A-Za-z_]\w*|\d+|[^\sA-Za-z0-9_]")
|
||||
DUPLICATION_WINDOW: int = 6
|
||||
MAINTAINABILITY_CEILING: float = 171.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileMetrics:
|
||||
total_lines: int
|
||||
sloc: int
|
||||
blank_lines: int
|
||||
comment_lines: int
|
||||
blank_ratio: float
|
||||
comment_ratio: float
|
||||
avg_line_length: float
|
||||
max_line_length: int
|
||||
avg_leading_spaces: float
|
||||
avg_leading_tabs: float
|
||||
indent_variance: float
|
||||
function_count: int
|
||||
docstring_count: int
|
||||
max_function_length: int
|
||||
cyclomatic_total: int
|
||||
cyclomatic_max_estimate: int
|
||||
duplication_ratio: float
|
||||
maintainability_index: float
|
||||
import_count: int
|
||||
|
||||
|
||||
def shannon_entropy(value: str) -> float:
|
||||
if not value:
|
||||
return 0.0
|
||||
counts: dict[str, int] = {}
|
||||
for char in value:
|
||||
counts[char] = counts.get(char, 0) + 1
|
||||
total = len(value)
|
||||
return -sum((count / total) * math.log2(count / total) for count in counts.values())
|
||||
|
||||
|
||||
def _leading_whitespace(lines: list[str]) -> tuple[float, float, float]:
|
||||
spaces: list[int] = []
|
||||
tabs: list[int] = []
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
continue
|
||||
space_count = len(line) - len(line.lstrip(" "))
|
||||
tab_count = len(line) - len(line.lstrip("\t"))
|
||||
spaces.append(space_count)
|
||||
tabs.append(tab_count)
|
||||
if not spaces:
|
||||
return 0.0, 0.0, 0.0
|
||||
mean_spaces = sum(spaces) / len(spaces)
|
||||
mean_tabs = sum(tabs) / len(tabs)
|
||||
variance = sum((value - mean_spaces) ** 2 for value in spaces) / len(spaces)
|
||||
return mean_spaces, mean_tabs, math.sqrt(variance)
|
||||
|
||||
|
||||
def _duplication_ratio(lines: list[str]) -> float:
|
||||
normalized = [re.sub(r"\s+", " ", line.strip()) for line in lines if line.strip()]
|
||||
if len(normalized) < DUPLICATION_WINDOW * 2:
|
||||
return 0.0
|
||||
seen: dict[str, int] = {}
|
||||
duplicated = 0
|
||||
windows = 0
|
||||
for index in range(len(normalized) - DUPLICATION_WINDOW + 1):
|
||||
window = "\n".join(normalized[index:index + DUPLICATION_WINDOW])
|
||||
if len(window) < 60:
|
||||
continue
|
||||
digest = hashlib.sha1(window.encode("utf-8")).hexdigest()
|
||||
windows += 1
|
||||
if digest in seen:
|
||||
duplicated += 1
|
||||
seen[digest] = index
|
||||
return duplicated / windows if windows else 0.0
|
||||
|
||||
|
||||
def _function_lengths(language: str, lines: list[str]) -> tuple[int, int]:
|
||||
pattern = FUNCTION_PATTERNS.get(language)
|
||||
if pattern is None:
|
||||
return 0, 0
|
||||
starts: list[int] = []
|
||||
for number, line in enumerate(lines):
|
||||
if pattern.match(line):
|
||||
starts.append(number)
|
||||
if not starts:
|
||||
return 0, 0
|
||||
lengths: list[int] = []
|
||||
for index, start in enumerate(starts):
|
||||
end = starts[index + 1] if index + 1 < len(starts) else len(lines)
|
||||
lengths.append(end - start)
|
||||
return len(starts), max(lengths)
|
||||
|
||||
|
||||
def _maintainability(sloc: int, cyclomatic: int, tokens: list[str]) -> float:
|
||||
if sloc <= 0 or not tokens:
|
||||
return 100.0
|
||||
unique = len(set(tokens))
|
||||
volume = len(tokens) * math.log2(max(unique, 2))
|
||||
raw = MAINTAINABILITY_CEILING - 5.2 * math.log(max(volume, 1.0)) - 0.23 * cyclomatic - 16.2 * math.log(max(sloc, 1))
|
||||
return max(0.0, raw * 100.0 / MAINTAINABILITY_CEILING)
|
||||
|
||||
|
||||
def compute_metrics(language: str, text: str) -> FileMetrics:
|
||||
lines = text.splitlines()
|
||||
total = len(lines)
|
||||
blank = sum(1 for line in lines if not line.strip())
|
||||
comments = extract_comments(language, lines)
|
||||
comment_lines = len(comments)
|
||||
sloc = max(0, total - blank - comment_lines)
|
||||
lengths = [len(line) for line in lines if line.strip()]
|
||||
avg_length = sum(lengths) / len(lengths) if lengths else 0.0
|
||||
max_length = max(lengths) if lengths else 0
|
||||
mean_spaces, mean_tabs, indent_deviation = _leading_whitespace(lines)
|
||||
function_count, max_function_length = _function_lengths(language, lines)
|
||||
docstring_count = len(DOCSTRING_PATTERN.findall(text)) if language == "python" else 0
|
||||
cyclomatic = len(COMPLEXITY_TOKENS.findall(text))
|
||||
per_function = cyclomatic // function_count if function_count else cyclomatic
|
||||
tokens = TOKEN_PATTERN.findall(text)[:20000]
|
||||
import_count = len(re.findall(r"^\s*(?:import|from|require|use|#include)\b", text, re.MULTILINE))
|
||||
return FileMetrics(
|
||||
total_lines=total,
|
||||
sloc=sloc,
|
||||
blank_lines=blank,
|
||||
comment_lines=comment_lines,
|
||||
blank_ratio=blank / total if total else 0.0,
|
||||
comment_ratio=comment_lines / total if total else 0.0,
|
||||
avg_line_length=avg_length,
|
||||
max_line_length=max_length,
|
||||
avg_leading_spaces=mean_spaces,
|
||||
avg_leading_tabs=mean_tabs,
|
||||
indent_variance=indent_deviation,
|
||||
function_count=function_count,
|
||||
docstring_count=docstring_count,
|
||||
max_function_length=max_function_length,
|
||||
cyclomatic_total=cyclomatic,
|
||||
cyclomatic_max_estimate=per_function,
|
||||
duplication_ratio=_duplication_ratio(lines),
|
||||
maintainability_index=_maintainability(sloc, per_function, tokens),
|
||||
import_count=import_count,
|
||||
)
|
||||
302
devplacepy/services/jobs/isslop/analysis/scoring.py
Normal file
302
devplacepy/services/jobs/isslop/analysis/scoring.py
Normal file
@ -0,0 +1,302 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.aggregate import DomEvidence
|
||||
from devplacepy.services.jobs.isslop.analysis.metrics import FileMetrics
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_ORIGIN,
|
||||
SEVERITY_FACTORS,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_STRONG,
|
||||
Signal,
|
||||
)
|
||||
from devplacepy.services.jobs.isslop.config import DOM_AI_WEIGHT
|
||||
|
||||
CATEGORY_SLOP: str = "ai-slop"
|
||||
CATEGORY_SOPHISTICATED: str = "sophisticated-ai"
|
||||
CATEGORY_HUMAN_CLEAN: str = "human-clean"
|
||||
CATEGORY_HUMAN_MESSY: str = "human-messy"
|
||||
CATEGORY_UNCERTAIN: str = "uncertain"
|
||||
|
||||
ORIGIN_HIGH_THRESHOLD: float = 55.0
|
||||
ORIGIN_LOW_THRESHOLD: float = 45.0
|
||||
QUALITY_SLOP_THRESHOLD: float = 50.0
|
||||
QUALITY_CLEAN_THRESHOLD: float = 30.0
|
||||
QUALITY_SATURATION: float = 14.0
|
||||
ORIGIN_SIGNAL_CAP: float = 45.0
|
||||
ORIGIN_SIGNAL_SCALE: float = 4.5
|
||||
|
||||
CRITICAL_PATH_PATTERN = re.compile(r"auth|login|password|payment|billing|crypto|security|token|session|admin", re.IGNORECASE)
|
||||
CRITICALITY_WEIGHT: float = 1.5
|
||||
|
||||
GRADE_BANDS: tuple[tuple[float, str], ...] = ((15.0, "A"), (30.0, "B"), (50.0, "C"), (70.0, "D"), (100.0, "F"))
|
||||
|
||||
AI_SCORE_WEIGHT: float = 0.68
|
||||
QUALITY_SCORE_WEIGHT: float = 0.32
|
||||
|
||||
AI_RAMP_LOW: float = 35.0
|
||||
AI_RAMP_HIGH: float = 65.0
|
||||
|
||||
IMAGE_TEXT_AI_WEIGHT: float = 0.75
|
||||
IMAGE_AI_WEIGHT: float = 0.25
|
||||
|
||||
|
||||
def compose_slop_score(ai_percent: float, quality_deficit: float) -> float:
|
||||
return round(AI_SCORE_WEIGHT * ai_percent + QUALITY_SCORE_WEIGHT * quality_deficit, 1)
|
||||
|
||||
|
||||
def ai_fraction(origin: float) -> float:
|
||||
if origin <= AI_RAMP_LOW:
|
||||
return 0.0
|
||||
if origin >= AI_RAMP_HIGH:
|
||||
return 1.0
|
||||
position = (origin - AI_RAMP_LOW) / (AI_RAMP_HIGH - AI_RAMP_LOW)
|
||||
return position * position * (3.0 - 2.0 * position)
|
||||
|
||||
HUMAN_MARKER_PATTERN = re.compile(r"\b(HACK|FIXME|XXX)\b.*[A-Z]{2,}-\d+|\bFIXME\(\w+\)", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileScore:
|
||||
relative: str
|
||||
language: str
|
||||
sloc: int
|
||||
origin_score: float
|
||||
quality_deficit: float
|
||||
category: str
|
||||
criticality: float
|
||||
signals: list[Signal] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RepoScores:
|
||||
origin_score: float
|
||||
quality_deficit: float
|
||||
slop_score: float
|
||||
grade: str
|
||||
category: str
|
||||
human_percent: float
|
||||
ai_percent: float
|
||||
confidence: str
|
||||
strong_signal_count: int
|
||||
medium_signal_count: int
|
||||
files_scored: int
|
||||
|
||||
|
||||
def quality_deficit_score(signals: list[Signal], metrics: FileMetrics) -> float:
|
||||
weighted = sum(
|
||||
signal.weight * SEVERITY_FACTORS.get(signal.severity, 0.3)
|
||||
for signal in signals
|
||||
if signal.axis != AXIS_ORIGIN
|
||||
)
|
||||
if metrics.maintainability_index < 40.0:
|
||||
weighted += 2.0
|
||||
elif metrics.maintainability_index < 65.0:
|
||||
weighted += 1.0
|
||||
return round(100.0 * (1.0 - math.exp(-weighted / QUALITY_SATURATION)), 1)
|
||||
|
||||
|
||||
FINGERPRINT_ORIGIN_BASE: float = 50.0
|
||||
FINGERPRINT_ORIGIN_SCALE: float = 7.0
|
||||
|
||||
|
||||
def fingerprint_origin_score(signals: list[Signal]) -> float:
|
||||
origin_weight = sum(
|
||||
signal.weight * SEVERITY_FACTORS.get(signal.severity, 0.3)
|
||||
for signal in signals
|
||||
if signal.axis == AXIS_ORIGIN
|
||||
)
|
||||
if origin_weight <= 0.0:
|
||||
return FINGERPRINT_ORIGIN_BASE
|
||||
score = FINGERPRINT_ORIGIN_BASE + origin_weight * FINGERPRINT_ORIGIN_SCALE
|
||||
if any(signal.code in ("AI_BUILDER_FINGERPRINT", "VIBE_STACK") for signal in signals):
|
||||
score += 12.0
|
||||
return round(min(100.0, score), 1)
|
||||
|
||||
|
||||
def origin_score(text: str, metrics: FileMetrics, signals: list[Signal]) -> float:
|
||||
score = 0.0
|
||||
if metrics.sloc >= 40:
|
||||
if metrics.indent_variance < 0.45:
|
||||
score += 16.0
|
||||
elif metrics.indent_variance < 1.2:
|
||||
score += 8.0
|
||||
elif metrics.indent_variance > 4.0:
|
||||
score -= 14.0
|
||||
if 0.10 <= metrics.blank_ratio <= 0.22 and metrics.total_lines >= 60:
|
||||
score += 6.0
|
||||
if metrics.comment_ratio >= 0.22 and metrics.sloc >= 40:
|
||||
score += 8.0
|
||||
if metrics.function_count >= 4 and metrics.docstring_count >= metrics.function_count:
|
||||
score += 10.0
|
||||
origin_weight = sum(
|
||||
signal.weight * SEVERITY_FACTORS.get(signal.severity, 0.3)
|
||||
for signal in signals
|
||||
if signal.axis == AXIS_ORIGIN
|
||||
)
|
||||
score += min(ORIGIN_SIGNAL_CAP, origin_weight * ORIGIN_SIGNAL_SCALE)
|
||||
if any(signal.code == "AI_SIGNATURE" for signal in signals):
|
||||
score += 22.0
|
||||
if any(signal.code == "NAMING_MIXED" for signal in signals):
|
||||
score -= 8.0
|
||||
if HUMAN_MARKER_PATTERN.search(text):
|
||||
score -= 10.0
|
||||
score += 24.0
|
||||
return round(min(100.0, max(0.0, score)), 1)
|
||||
|
||||
|
||||
def categorize(origin: float, quality: float) -> str:
|
||||
if origin >= ORIGIN_HIGH_THRESHOLD and quality >= QUALITY_SLOP_THRESHOLD:
|
||||
return CATEGORY_SLOP
|
||||
if origin >= ORIGIN_HIGH_THRESHOLD and quality < QUALITY_CLEAN_THRESHOLD:
|
||||
return CATEGORY_SOPHISTICATED
|
||||
if origin < ORIGIN_LOW_THRESHOLD:
|
||||
return CATEGORY_HUMAN_MESSY if quality >= QUALITY_SLOP_THRESHOLD else CATEGORY_HUMAN_CLEAN
|
||||
return CATEGORY_UNCERTAIN
|
||||
|
||||
|
||||
def criticality_for(relative: str) -> float:
|
||||
return CRITICALITY_WEIGHT if CRITICAL_PATH_PATTERN.search(relative) else 1.0
|
||||
|
||||
|
||||
def grade_for(slop: float) -> str:
|
||||
for ceiling, grade in GRADE_BANDS:
|
||||
if slop <= ceiling:
|
||||
return grade
|
||||
return "F"
|
||||
|
||||
|
||||
def _confidence(strong: int, medium: int, files: int) -> str:
|
||||
if files < 3:
|
||||
return "low"
|
||||
if strong >= 3:
|
||||
return "high"
|
||||
if strong >= 1 or medium >= 5:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
|
||||
def aggregate(files: list[FileScore]) -> RepoScores:
|
||||
scored = [entry for entry in files if entry.sloc > 0]
|
||||
if not scored:
|
||||
return RepoScores(0.0, 0.0, 0.0, "A", CATEGORY_UNCERTAIN, 50.0, 50.0, "low", 0, 0, 0)
|
||||
total_weight = sum(entry.sloc * entry.criticality for entry in scored)
|
||||
origin = sum(entry.origin_score * entry.sloc * entry.criticality for entry in scored) / total_weight
|
||||
quality = sum(entry.quality_deficit * entry.sloc * entry.criticality for entry in scored) / total_weight
|
||||
ai_mass = sum(entry.sloc * entry.criticality * ai_fraction(entry.origin_score) for entry in scored)
|
||||
ai_percent = round(100.0 * ai_mass / total_weight, 1)
|
||||
slop = compose_slop_score(ai_percent, quality)
|
||||
strong = sum(1 for entry in scored for signal in entry.signals if signal.severity == SEVERITY_STRONG)
|
||||
medium = sum(1 for entry in scored for signal in entry.signals if signal.severity == SEVERITY_MEDIUM)
|
||||
return RepoScores(
|
||||
origin_score=round(origin, 1),
|
||||
quality_deficit=round(quality, 1),
|
||||
slop_score=round(slop, 1),
|
||||
grade=grade_for(slop),
|
||||
category=categorize(origin, quality),
|
||||
human_percent=round(100.0 - ai_percent, 1),
|
||||
ai_percent=ai_percent,
|
||||
confidence=_confidence(strong, medium, len(scored)),
|
||||
strong_signal_count=strong,
|
||||
medium_signal_count=medium,
|
||||
files_scored=len(scored),
|
||||
)
|
||||
|
||||
|
||||
TEMPLATE_CONFIDENT_SCORE: float = 35.0
|
||||
TEMPLATE_STRONG_SCORE: float = 70.0
|
||||
TEMPLATE_INFLUENCE: float = 0.7
|
||||
TEMPLATE_STRONG_INFLUENCE: float = 0.85
|
||||
|
||||
DOM_BUILDER_CONFIDENT_THRESHOLD: float = 0.75
|
||||
DOM_BUILDER_FLOOR: float = 70.0
|
||||
|
||||
|
||||
def _force_slop_category(scores: RepoScores, floor: float) -> RepoScores:
|
||||
ai_percent = max(scores.ai_percent, floor)
|
||||
origin = max(scores.origin_score, floor)
|
||||
slop = compose_slop_score(ai_percent, scores.quality_deficit)
|
||||
return replace(
|
||||
scores,
|
||||
origin_score=origin,
|
||||
ai_percent=ai_percent,
|
||||
human_percent=round(100.0 - ai_percent, 1),
|
||||
slop_score=slop,
|
||||
grade=grade_for(slop),
|
||||
category=CATEGORY_SLOP,
|
||||
)
|
||||
|
||||
|
||||
def adjust_for_template(scores: RepoScores, template_score: float) -> RepoScores:
|
||||
if template_score < TEMPLATE_CONFIDENT_SCORE:
|
||||
return scores
|
||||
if template_score >= TEMPLATE_STRONG_SCORE:
|
||||
floor = round(TEMPLATE_STRONG_INFLUENCE * template_score, 1)
|
||||
return _force_slop_category(scores, floor)
|
||||
floor = round(TEMPLATE_INFLUENCE * template_score, 1)
|
||||
ai_percent = max(scores.ai_percent, floor)
|
||||
origin = max(scores.origin_score, floor)
|
||||
slop = compose_slop_score(ai_percent, scores.quality_deficit)
|
||||
category = categorize(origin, scores.quality_deficit)
|
||||
if category in (CATEGORY_HUMAN_CLEAN, CATEGORY_HUMAN_MESSY):
|
||||
category = CATEGORY_UNCERTAIN
|
||||
return replace(
|
||||
scores,
|
||||
origin_score=origin,
|
||||
ai_percent=ai_percent,
|
||||
human_percent=round(100.0 - ai_percent, 1),
|
||||
slop_score=slop,
|
||||
grade=grade_for(slop),
|
||||
category=category,
|
||||
)
|
||||
|
||||
|
||||
def adjust_for_dom_signals(scores: RepoScores, dom: DomEvidence) -> RepoScores:
|
||||
if dom.detected_builder is not None and dom.builder_confidence >= DOM_BUILDER_CONFIDENT_THRESHOLD:
|
||||
return _force_slop_category(scores, DOM_BUILDER_FLOOR)
|
||||
if dom.score <= 0:
|
||||
return scores
|
||||
ai_percent = round((1.0 - DOM_AI_WEIGHT) * scores.ai_percent + DOM_AI_WEIGHT * dom.score, 1)
|
||||
slop = compose_slop_score(ai_percent, scores.quality_deficit)
|
||||
return replace(
|
||||
scores,
|
||||
ai_percent=ai_percent,
|
||||
human_percent=round(100.0 - ai_percent, 1),
|
||||
slop_score=slop,
|
||||
grade=grade_for(slop),
|
||||
)
|
||||
|
||||
|
||||
def adjust_for_images(scores: RepoScores, mean_image_ai: float) -> RepoScores:
|
||||
ai_percent = round(
|
||||
IMAGE_TEXT_AI_WEIGHT * scores.ai_percent + IMAGE_AI_WEIGHT * mean_image_ai, 1
|
||||
)
|
||||
slop = compose_slop_score(ai_percent, scores.quality_deficit)
|
||||
return replace(
|
||||
scores,
|
||||
ai_percent=ai_percent,
|
||||
human_percent=round(100.0 - ai_percent, 1),
|
||||
slop_score=slop,
|
||||
grade=grade_for(slop),
|
||||
)
|
||||
|
||||
|
||||
def repo_scores_to_dict(scores: RepoScores) -> dict[str, Any]:
|
||||
return {
|
||||
"origin_score": scores.origin_score,
|
||||
"quality_deficit": scores.quality_deficit,
|
||||
"slop_score": scores.slop_score,
|
||||
"grade": scores.grade,
|
||||
"category": scores.category,
|
||||
"human_percent": scores.human_percent,
|
||||
"ai_percent": scores.ai_percent,
|
||||
"confidence": scores.confidence,
|
||||
"strong_signal_count": scores.strong_signal_count,
|
||||
"medium_signal_count": scores.medium_signal_count,
|
||||
"files_scored": scores.files_scored,
|
||||
}
|
||||
77
devplacepy/services/jobs/isslop/analysis/signals/__init__.py
Normal file
77
devplacepy/services/jobs/isslop/analysis/signals/__init__.py
Normal file
@ -0,0 +1,77 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.metrics import FileMetrics
|
||||
|
||||
SEVERITY_STRONG: str = "strong"
|
||||
SEVERITY_MEDIUM: str = "medium"
|
||||
SEVERITY_WEAK: str = "weak"
|
||||
|
||||
AXIS_ORIGIN: str = "origin"
|
||||
AXIS_QUALITY: str = "quality"
|
||||
|
||||
SEVERITY_FACTORS: dict[str, float] = {
|
||||
SEVERITY_STRONG: 1.0,
|
||||
SEVERITY_MEDIUM: 0.6,
|
||||
SEVERITY_WEAK: 0.3,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Signal:
|
||||
code: str
|
||||
title: str
|
||||
severity: str
|
||||
axis: str
|
||||
weight: float
|
||||
line: int
|
||||
evidence: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"code": self.code,
|
||||
"title": self.title,
|
||||
"severity": self.severity,
|
||||
"axis": self.axis,
|
||||
"weight": self.weight,
|
||||
"line": self.line,
|
||||
"evidence": self.evidence[:200],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RepoContext:
|
||||
root: Path
|
||||
python_dependencies: frozenset[str]
|
||||
javascript_dependencies: frozenset[str]
|
||||
local_python_modules: frozenset[str]
|
||||
has_python_manifest: bool
|
||||
has_javascript_manifest: bool
|
||||
javascript_alias_prefixes: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileContext:
|
||||
path: Path
|
||||
relative: str
|
||||
language: str
|
||||
text: str
|
||||
lines: list[str] = field(default_factory=list)
|
||||
comments: list[tuple[int, str]] = field(default_factory=list)
|
||||
metrics: FileMetrics | None = None
|
||||
repo: RepoContext | None = None
|
||||
fingerprint_only: bool = False
|
||||
|
||||
|
||||
Detector = Callable[[FileContext], list[Signal]]
|
||||
|
||||
|
||||
def run_detectors(context: FileContext, detectors: list[Detector]) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
for detector in detectors:
|
||||
findings.extend(detector(context))
|
||||
return findings
|
||||
@ -0,0 +1,114 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_ORIGIN,
|
||||
AXIS_QUALITY,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_WEAK,
|
||||
FileContext,
|
||||
Signal,
|
||||
)
|
||||
from devplacepy.services.jobs.isslop.analysis.signals.textual import EM_DASH_THRESHOLD, LLM_LEXICON_PATTERN, LLM_LEXICON_THRESHOLD
|
||||
|
||||
EMOJI_HEADER_PATTERN = re.compile(r"^#{1,4}\s*[\U0001F300-\U0001FAFF✨🚀🤝⭐✅❗]")
|
||||
BADGE_PATTERN = re.compile(r"!\[[^\]]*\]\([^)]*(?:badge|shields\.io)[^)]*\)")
|
||||
BOILERPLATE_PHRASES: tuple[str, ...] = (
|
||||
"getting started",
|
||||
"features",
|
||||
"installation",
|
||||
"prerequisites",
|
||||
"contributing",
|
||||
"built with",
|
||||
"tech stack",
|
||||
"acknowledgments",
|
||||
"roadmap",
|
||||
)
|
||||
MARKETING_PATTERN = re.compile(
|
||||
r"blazingly fast|seamlessly|robust and scalable|cutting[- ]edge|revolutioniz|effortlessly|supercharge|powerful and flexible",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
NOT_JUST_PATTERN = re.compile(r"it'?s not just \w+[,;.]? it'?s", re.IGNORECASE)
|
||||
|
||||
EMOJI_HEADER_THRESHOLD: int = 3
|
||||
BADGE_THRESHOLD: int = 6
|
||||
BOILERPLATE_THRESHOLD: int = 5
|
||||
MARKETING_THRESHOLD: int = 3
|
||||
|
||||
|
||||
def detect_documentation(context: FileContext) -> list[Signal]:
|
||||
if context.language != "markdown":
|
||||
return []
|
||||
findings: list[Signal] = []
|
||||
emoji_headers = [number for number, line in enumerate(context.lines, start=1) if EMOJI_HEADER_PATTERN.match(line)]
|
||||
if len(emoji_headers) >= EMOJI_HEADER_THRESHOLD:
|
||||
findings.append(
|
||||
Signal(
|
||||
"README_EMOJI_STRUCTURE",
|
||||
f"{len(emoji_headers)} emoji-prefixed headers in documentation",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
emoji_headers[0],
|
||||
context.lines[emoji_headers[0] - 1].strip(),
|
||||
)
|
||||
)
|
||||
lowered = context.text.lower()
|
||||
phrase_hits = sum(1 for phrase in BOILERPLATE_PHRASES if phrase in lowered)
|
||||
if phrase_hits >= BOILERPLATE_THRESHOLD and emoji_headers:
|
||||
findings.append(
|
||||
Signal(
|
||||
"README_BOILERPLATE",
|
||||
f"Generic AI README structure ({phrase_hits} boilerplate sections with emoji headers)",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
1,
|
||||
", ".join(phrase for phrase in BOILERPLATE_PHRASES if phrase in lowered)[:150],
|
||||
)
|
||||
)
|
||||
badges = BADGE_PATTERN.findall(context.text)
|
||||
if len(badges) >= BADGE_THRESHOLD:
|
||||
findings.append(
|
||||
Signal("README_BADGE_STUFFING", f"{len(badges)} badges stacked in documentation", SEVERITY_WEAK, AXIS_QUALITY, 1.0, 1, badges[0][:100])
|
||||
)
|
||||
marketing_hits = MARKETING_PATTERN.findall(context.text)
|
||||
if len(marketing_hits) >= MARKETING_THRESHOLD:
|
||||
findings.append(
|
||||
Signal("MARKETING_TONE", f"Marketing tone in documentation ({len(marketing_hits)} phrases)", SEVERITY_WEAK, AXIS_QUALITY, 1.0, 1, ", ".join(marketing_hits[:4]))
|
||||
)
|
||||
lexicon_hits = {str(hit).lower() for hit in LLM_LEXICON_PATTERN.findall(context.text)}
|
||||
if len(lexicon_hits) >= LLM_LEXICON_THRESHOLD:
|
||||
findings.append(
|
||||
Signal(
|
||||
"LLM_LEXICON",
|
||||
f"{len(lexicon_hits)} distinct LLM signature words in documentation",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
1,
|
||||
", ".join(sorted(lexicon_hits)[:6]),
|
||||
)
|
||||
)
|
||||
em_dashes = context.text.count("\u2014")
|
||||
if em_dashes >= EM_DASH_THRESHOLD:
|
||||
findings.append(
|
||||
Signal(
|
||||
"EM_DASH_OVERUSE",
|
||||
f"{em_dashes} em dashes in documentation, a strong LLM prose habit",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
1,
|
||||
f"{em_dashes} em dashes",
|
||||
)
|
||||
)
|
||||
not_just = NOT_JUST_PATTERN.search(context.text)
|
||||
if not_just:
|
||||
line = context.text.count("\n", 0, not_just.start()) + 1
|
||||
findings.append(
|
||||
Signal("AI_PROSE_PATTERN", "Characteristic AI prose construction", SEVERITY_WEAK, AXIS_ORIGIN, 1.0, line, not_just.group(0))
|
||||
)
|
||||
return findings
|
||||
138
devplacepy/services/jobs/isslop/analysis/signals/errors.py
Normal file
138
devplacepy/services/jobs/isslop/analysis/signals/errors.py
Normal file
@ -0,0 +1,138 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_QUALITY,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_STRONG,
|
||||
FileContext,
|
||||
Signal,
|
||||
)
|
||||
|
||||
BARE_EXCEPT_PATTERN = re.compile(r"^\s*except\s*:\s*(#.*)?$")
|
||||
BROAD_EXCEPT_PATTERN = re.compile(r"^\s*except\s+(?:Exception|BaseException)\s*(?:as\s+\w+)?\s*:\s*(#.*)?$")
|
||||
PASS_PATTERN = re.compile(r"^\s*pass\s*(#.*)?$")
|
||||
EMPTY_CATCH_PATTERN = re.compile(r"catch\s*(\([^)]*\))?\s*\{\s*\}")
|
||||
LOG_ONLY_CATCH_PATTERN = re.compile(r"catch\s*\(\s*(\w+)\s*\)\s*\{\s*console\.(log|error)\([^)]*\);?\s*\}")
|
||||
DEBUGGER_PATTERN = re.compile(r"^\s*debugger\s*;?\s*$")
|
||||
TODO_REMOVE_PATTERN = re.compile(r"TODO:? remove", re.IGNORECASE)
|
||||
CONSOLE_LOG_PATTERN = re.compile(r"\bconsole\.log\s*\(")
|
||||
PRINT_PATTERN = re.compile(r"^\s*print\s*\(")
|
||||
|
||||
CONSOLE_LOG_THRESHOLD: int = 6
|
||||
PRINT_DENSITY_THRESHOLD: float = 0.06
|
||||
PRINT_MIN_SLOC: int = 60
|
||||
|
||||
|
||||
def _python_swallowed(context: FileContext) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
for index, line in enumerate(context.lines):
|
||||
bare = BARE_EXCEPT_PATTERN.match(line)
|
||||
broad = BROAD_EXCEPT_PATTERN.match(line)
|
||||
if not bare and not broad:
|
||||
continue
|
||||
follow = ""
|
||||
for candidate in context.lines[index + 1: index + 3]:
|
||||
if candidate.strip():
|
||||
follow = candidate
|
||||
break
|
||||
swallows = bool(PASS_PATTERN.match(follow))
|
||||
if bare:
|
||||
findings.append(
|
||||
Signal(
|
||||
"SWALLOWED_ERROR" if swallows else "BARE_EXCEPT",
|
||||
"Bare except swallowing all errors" if swallows else "Bare except clause",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_QUALITY,
|
||||
3.0,
|
||||
index + 1,
|
||||
line.strip(),
|
||||
)
|
||||
)
|
||||
elif swallows:
|
||||
findings.append(
|
||||
Signal(
|
||||
"SWALLOWED_ERROR",
|
||||
"except Exception: pass swallows all errors",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_QUALITY,
|
||||
3.0,
|
||||
index + 1,
|
||||
f"{line.strip()} / pass",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def detect_error_handling(context: FileContext) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
if context.language == "python":
|
||||
findings.extend(_python_swallowed(context))
|
||||
metrics = context.metrics
|
||||
if metrics is not None and metrics.sloc >= PRINT_MIN_SLOC:
|
||||
prints = sum(1 for line in context.lines if PRINT_PATTERN.match(line))
|
||||
if prints / max(metrics.sloc, 1) > PRINT_DENSITY_THRESHOLD:
|
||||
findings.append(
|
||||
Signal(
|
||||
"DEBUG_LEFTOVERS",
|
||||
f"{prints} print() calls scattered through module",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
2.0,
|
||||
1,
|
||||
f"{prints} print calls in {metrics.sloc} SLOC",
|
||||
)
|
||||
)
|
||||
if context.language in ("javascript", "typescript", "java", "php", "csharp"):
|
||||
for match in EMPTY_CATCH_PATTERN.finditer(context.text):
|
||||
line = context.text.count("\n", 0, match.start()) + 1
|
||||
findings.append(
|
||||
Signal(
|
||||
"SWALLOWED_ERROR",
|
||||
"Empty catch block swallows all errors",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_QUALITY,
|
||||
3.0,
|
||||
line,
|
||||
match.group(0)[:120],
|
||||
)
|
||||
)
|
||||
for match in LOG_ONLY_CATCH_PATTERN.finditer(context.text):
|
||||
line = context.text.count("\n", 0, match.start()) + 1
|
||||
findings.append(
|
||||
Signal(
|
||||
"LOG_ONLY_CATCH",
|
||||
"Catch block only logs to console",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
3.0,
|
||||
line,
|
||||
match.group(0)[:120],
|
||||
)
|
||||
)
|
||||
console_hits = len(CONSOLE_LOG_PATTERN.findall(context.text))
|
||||
if console_hits >= CONSOLE_LOG_THRESHOLD and "test" not in context.relative.lower():
|
||||
findings.append(
|
||||
Signal(
|
||||
"DEBUG_LEFTOVERS",
|
||||
f"{console_hits} console.log calls left in source",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
2.0,
|
||||
1,
|
||||
f"{console_hits} console.log calls",
|
||||
)
|
||||
)
|
||||
for number, line in enumerate(context.lines, start=1):
|
||||
if DEBUGGER_PATTERN.match(line):
|
||||
findings.append(
|
||||
Signal("DEBUG_LEFTOVERS", "debugger statement left in source", SEVERITY_MEDIUM, AXIS_QUALITY, 2.0, number, line.strip())
|
||||
)
|
||||
for number, comment in context.comments:
|
||||
if TODO_REMOVE_PATTERN.search(comment):
|
||||
findings.append(
|
||||
Signal("DEBUG_LEFTOVERS", "TODO remove marker left in source", SEVERITY_MEDIUM, AXIS_QUALITY, 2.0, number, comment)
|
||||
)
|
||||
return findings
|
||||
@ -0,0 +1,139 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_QUALITY,
|
||||
SEVERITY_MEDIUM,
|
||||
FileContext,
|
||||
Signal,
|
||||
)
|
||||
|
||||
PYTHON_IMPORT_PATTERN = re.compile(r"^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))", re.MULTILINE)
|
||||
JS_IMPORT_PATTERN = re.compile(r"""(?:import[^'"]*from\s*|import\s*\(\s*|require\s*\(\s*)['"]([^'"]+)['"]""")
|
||||
|
||||
PYTHON_IMPORT_ALIASES: dict[str, str] = {
|
||||
"pil": "pillow",
|
||||
"cv2": "opencv-python",
|
||||
"yaml": "pyyaml",
|
||||
"bs4": "beautifulsoup4",
|
||||
"dotenv": "python-dotenv",
|
||||
"sklearn": "scikit-learn",
|
||||
"dateutil": "python-dateutil",
|
||||
"jose": "python-jose",
|
||||
"jwt": "pyjwt",
|
||||
"magic": "python-magic",
|
||||
"serial": "pyserial",
|
||||
"usb": "pyusb",
|
||||
"redis": "redis",
|
||||
"attr": "attrs",
|
||||
"google": "google",
|
||||
"pkg_resources": "setuptools",
|
||||
"setuptools": "setuptools",
|
||||
}
|
||||
|
||||
NODE_BUILTINS: frozenset[str] = frozenset(
|
||||
{
|
||||
"assert", "async_hooks", "buffer", "child_process", "cluster", "console", "constants",
|
||||
"crypto", "dgram", "diagnostics_channel", "dns", "domain", "events", "fs", "http", "http2",
|
||||
"https", "inspector", "module", "net", "os", "path", "perf_hooks", "process", "punycode",
|
||||
"querystring", "readline", "repl", "sea", "sqlite", "stream", "string_decoder", "test",
|
||||
"timers", "tls", "trace_events", "tty", "url", "util", "v8", "vm", "wasi",
|
||||
"worker_threads", "zlib",
|
||||
}
|
||||
)
|
||||
|
||||
NON_PACKAGE_PREFIXES: tuple[str, ...] = ("@/", "~", "#", "$")
|
||||
|
||||
MAX_FLAGS_PER_FILE: int = 5
|
||||
|
||||
|
||||
def _normalize_package(name: str) -> str:
|
||||
return name.lower().replace("-", "_").replace(".", "_")
|
||||
|
||||
|
||||
def _python_findings(context: FileContext) -> list[Signal]:
|
||||
repo = context.repo
|
||||
if repo is None or not repo.has_python_manifest:
|
||||
return []
|
||||
known = {_normalize_package(dep) for dep in repo.python_dependencies}
|
||||
local = {_normalize_package(module) for module in repo.local_python_modules}
|
||||
findings: list[Signal] = []
|
||||
seen: set[str] = set()
|
||||
for match in PYTHON_IMPORT_PATTERN.finditer(context.text):
|
||||
module = (match.group(1) or match.group(2) or "").split(".")[0]
|
||||
if not module or module in seen:
|
||||
continue
|
||||
seen.add(module)
|
||||
normalized = _normalize_package(module)
|
||||
aliased = _normalize_package(PYTHON_IMPORT_ALIASES.get(normalized, normalized))
|
||||
if normalized in sys.stdlib_module_names or module in sys.stdlib_module_names:
|
||||
continue
|
||||
if normalized in known or aliased in known or normalized in local:
|
||||
continue
|
||||
line = context.text.count("\n", 0, match.start()) + 1
|
||||
findings.append(
|
||||
Signal(
|
||||
"DEP_UNRESOLVED",
|
||||
f"Import '{module}' resolves to no manifest dependency, stdlib or local module",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
5.0,
|
||||
line,
|
||||
match.group(0).strip(),
|
||||
)
|
||||
)
|
||||
return findings[:MAX_FLAGS_PER_FILE]
|
||||
|
||||
|
||||
def _javascript_findings(context: FileContext) -> list[Signal]:
|
||||
repo = context.repo
|
||||
if repo is None or not repo.has_javascript_manifest:
|
||||
return []
|
||||
known = {dep.lower() for dep in repo.javascript_dependencies}
|
||||
findings: list[Signal] = []
|
||||
seen: set[str] = set()
|
||||
aliases = tuple(repo.javascript_alias_prefixes)
|
||||
for match in JS_IMPORT_PATTERN.finditer(context.text):
|
||||
target = match.group(1)
|
||||
if target.startswith((".", "/", "http:", "https:")):
|
||||
continue
|
||||
if target.startswith(NON_PACKAGE_PREFIXES):
|
||||
continue
|
||||
if aliases and target.startswith(aliases):
|
||||
continue
|
||||
stripped = target.removeprefix("node:")
|
||||
parts = stripped.split("/")
|
||||
package = "/".join(parts[:2]) if stripped.startswith("@") and len(parts) >= 2 else parts[0]
|
||||
if package in seen:
|
||||
continue
|
||||
seen.add(package)
|
||||
if ":" in package:
|
||||
continue
|
||||
if package.lower() in NODE_BUILTINS or target.startswith("node:"):
|
||||
continue
|
||||
if package.lower() in known:
|
||||
continue
|
||||
line = context.text.count("\n", 0, match.start()) + 1
|
||||
findings.append(
|
||||
Signal(
|
||||
"DEP_UNRESOLVED",
|
||||
f"Import '{package}' is not declared in package.json",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
5.0,
|
||||
line,
|
||||
match.group(0).strip()[:120],
|
||||
)
|
||||
)
|
||||
return findings[:MAX_FLAGS_PER_FILE]
|
||||
|
||||
|
||||
def detect_hallucination(context: FileContext) -> list[Signal]:
|
||||
if context.language == "python":
|
||||
return _python_findings(context)
|
||||
if context.language in ("javascript", "typescript"):
|
||||
return _javascript_findings(context)
|
||||
return []
|
||||
@ -0,0 +1,96 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_ORIGIN,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_STRONG,
|
||||
SEVERITY_WEAK,
|
||||
FileContext,
|
||||
Signal,
|
||||
)
|
||||
|
||||
PATH_FRAMEWORK_PATTERNS: tuple[tuple[re.Pattern[str], str, str], ...] = (
|
||||
(re.compile(r"(^|/)_next/", re.IGNORECASE), "Next.js build output (_next/)", SEVERITY_WEAK),
|
||||
(re.compile(r"(^|/)_nuxt/", re.IGNORECASE), "Nuxt build output (_nuxt/)", SEVERITY_WEAK),
|
||||
(re.compile(r"(^|/)_astro/", re.IGNORECASE), "Astro build output (_astro/)", SEVERITY_WEAK),
|
||||
(re.compile(r"(^|/)\.svelte-kit/", re.IGNORECASE), "SvelteKit build output", SEVERITY_WEAK),
|
||||
(re.compile(r"(^|/)webpack-[0-9a-f]{8,}\.js$", re.IGNORECASE), "Webpack runtime chunk", SEVERITY_WEAK),
|
||||
(re.compile(r"(^|/)polyfills-[0-9a-f]{6,}\.js$", re.IGNORECASE), "Bundler polyfills chunk", SEVERITY_WEAK),
|
||||
(re.compile(r"(^|/)main-app-[0-9a-f]{6,}\.js$", re.IGNORECASE), "Next.js app-router bundle", SEVERITY_MEDIUM),
|
||||
(re.compile(r"(^|/)chunks/app/(page|layout|loading|error|not-found)-[0-9a-f]{6,}\.js$", re.IGNORECASE), "Next.js app-router page bundle", SEVERITY_MEDIUM),
|
||||
(re.compile(r"(^|/)framework-[0-9a-f]{8,}\.js$", re.IGNORECASE), "Next.js framework chunk", SEVERITY_WEAK),
|
||||
(re.compile(r"(^|/)assets/index-[0-9A-Za-z_-]{8,}\.(js|css)$"), "Vite hashed asset bundle", SEVERITY_WEAK),
|
||||
(re.compile(r"(^|/)gptengineer\.js$|(^|/)cdn\.gpteng\.co", re.IGNORECASE), "GPT Engineer / Lovable runtime", SEVERITY_STRONG),
|
||||
(re.compile(r"(^|/)lovable-uploads/", re.IGNORECASE), "Lovable uploads directory", SEVERITY_STRONG),
|
||||
(re.compile(r"(^|/)gatsby-", re.IGNORECASE), "Gatsby build artifact", SEVERITY_WEAK),
|
||||
)
|
||||
|
||||
VITE_HASH_ASSET = re.compile(r"index-[0-9A-Za-z_-]{8,}\.(js|css)$")
|
||||
|
||||
CONTENT_FRAMEWORK_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(re.compile(r"self\.__next_f|__NEXT_DATA__|next/dist/", re.IGNORECASE), "next.js runtime"),
|
||||
(re.compile(r"__nuxt|nuxt\.config", re.IGNORECASE), "nuxt runtime"),
|
||||
(re.compile(r"astro-island|astro:", re.IGNORECASE), "astro runtime"),
|
||||
(re.compile(r"data-radix|@radix-ui|radix-ui", re.IGNORECASE), "radix primitives"),
|
||||
(re.compile(r"lucide", re.IGNORECASE), "lucide icons"),
|
||||
(re.compile(r"shadcn|class-variance-authority|\bcn\(", re.IGNORECASE), "shadcn/ui"),
|
||||
(re.compile(r"tailwind|--tw-", re.IGNORECASE), "tailwind"),
|
||||
(re.compile(r"framer-motion", re.IGNORECASE), "framer motion"),
|
||||
(re.compile(r"createClient\([^)]*supabase|supabase\.co", re.IGNORECASE), "supabase backend"),
|
||||
(re.compile(r"clerk\.|@clerk/", re.IGNORECASE), "clerk auth"),
|
||||
)
|
||||
|
||||
CONTENT_STRONG_MARKERS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(re.compile(r"gpteng|lovable|data-lov-id", re.IGNORECASE), "Lovable/GPT-Engineer marker"),
|
||||
(re.compile(r"generated by (v0|bolt|base44|lovable)", re.IGNORECASE), "AI builder attribution"),
|
||||
)
|
||||
|
||||
VIBE_COMBO_THRESHOLD: int = 3
|
||||
|
||||
|
||||
def detect_infrastructure(context: FileContext) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
relative = context.relative
|
||||
|
||||
for pattern, title, severity in PATH_FRAMEWORK_PATTERNS:
|
||||
if pattern.search(relative):
|
||||
weight = 3.0 if severity == SEVERITY_STRONG else (1.0 if severity == SEVERITY_MEDIUM else 0.5)
|
||||
findings.append(
|
||||
Signal("BUILD_ARTIFACT_PATH", f"Framework build artifact path: {title}", severity, AXIS_ORIGIN, weight, 1, relative)
|
||||
)
|
||||
break
|
||||
|
||||
text = context.text
|
||||
for pattern, marker in CONTENT_STRONG_MARKERS:
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
findings.append(
|
||||
Signal(
|
||||
"AI_BUILDER_FINGERPRINT",
|
||||
f"AI builder marker in bundle: {marker}",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_ORIGIN,
|
||||
4.0,
|
||||
text.count("\n", 0, match.start()) + 1,
|
||||
match.group(0)[:100],
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
stack_hits = [label for pattern, label in CONTENT_FRAMEWORK_PATTERNS if pattern.search(text)]
|
||||
if len(stack_hits) >= VIBE_COMBO_THRESHOLD:
|
||||
findings.append(
|
||||
Signal(
|
||||
"VIBE_STACK",
|
||||
f"Default AI frontend stack in bundle ({len(stack_hits)} libraries)",
|
||||
SEVERITY_STRONG if len(stack_hits) >= 5 else SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
3.0,
|
||||
1,
|
||||
", ".join(stack_hits[:8]),
|
||||
)
|
||||
)
|
||||
return findings
|
||||
98
devplacepy/services/jobs/isslop/analysis/signals/language.py
Normal file
98
devplacepy/services/jobs/isslop/analysis/signals/language.py
Normal file
@ -0,0 +1,98 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_QUALITY,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_WEAK,
|
||||
FileContext,
|
||||
Signal,
|
||||
)
|
||||
|
||||
VAR_PATTERN = re.compile(r"^\s*var\s+\w+")
|
||||
MODERN_JS_PATTERN = re.compile(r"\b(?:const|let|=>|class\s+\w+|import\s)")
|
||||
USE_EFFECT_FETCH_PATTERN = re.compile(r"useEffect\s*\(\s*(?:async\s*)?\(\s*\)\s*=>\s*\{[^}]{0,400}\bfetch\s*\(", re.DOTALL)
|
||||
ANY_TYPE_PATTERN = re.compile(r":\s*any\b")
|
||||
OPEN_WITHOUT_WITH_PATTERN = re.compile(r"^\s*\w+\s*=\s*open\s*\(")
|
||||
BROAD_EXCEPT_COUNT_PATTERN = re.compile(r"^\s*except\s+Exception\b", re.MULTILINE)
|
||||
INLINE_STYLE_PATTERN = re.compile(r"\sstyle\s*=\s*\"")
|
||||
|
||||
VAR_THRESHOLD: int = 3
|
||||
ANY_THRESHOLD: int = 5
|
||||
OPEN_THRESHOLD: int = 2
|
||||
BROAD_EXCEPT_THRESHOLD: int = 3
|
||||
INLINE_STYLE_THRESHOLD: int = 10
|
||||
|
||||
|
||||
def detect_language_tells(context: FileContext) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
if context.language in ("javascript", "typescript"):
|
||||
var_lines = [number for number, line in enumerate(context.lines, start=1) if VAR_PATTERN.match(line)]
|
||||
if len(var_lines) >= VAR_THRESHOLD and MODERN_JS_PATTERN.search(context.text):
|
||||
findings.append(
|
||||
Signal(
|
||||
"LEGACY_VAR",
|
||||
f"{len(var_lines)} var declarations mixed into modern code",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
var_lines[0],
|
||||
context.lines[var_lines[0] - 1].strip(),
|
||||
)
|
||||
)
|
||||
effect_match = USE_EFFECT_FETCH_PATTERN.search(context.text)
|
||||
if effect_match:
|
||||
line = context.text.count("\n", 0, effect_match.start()) + 1
|
||||
findings.append(
|
||||
Signal(
|
||||
"USEEFFECT_FETCH",
|
||||
"Data fetching inside useEffect without caching or retry",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
2.0,
|
||||
line,
|
||||
effect_match.group(0)[:120],
|
||||
)
|
||||
)
|
||||
if context.language == "typescript":
|
||||
any_hits = ANY_TYPE_PATTERN.findall(context.text)
|
||||
if len(any_hits) >= ANY_THRESHOLD:
|
||||
findings.append(
|
||||
Signal("ANY_TYPES", f"{len(any_hits)} explicit any types", SEVERITY_MEDIUM, AXIS_QUALITY, 1.0, 1, f"{len(any_hits)} occurrences of : any")
|
||||
)
|
||||
if context.language == "python":
|
||||
open_lines = [number for number, line in enumerate(context.lines, start=1) if OPEN_WITHOUT_WITH_PATTERN.match(line)]
|
||||
if len(open_lines) >= OPEN_THRESHOLD:
|
||||
findings.append(
|
||||
Signal(
|
||||
"NO_CONTEXT_MANAGER",
|
||||
f"{len(open_lines)} open() calls without context manager",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
open_lines[0],
|
||||
context.lines[open_lines[0] - 1].strip(),
|
||||
)
|
||||
)
|
||||
broad = BROAD_EXCEPT_COUNT_PATTERN.findall(context.text)
|
||||
if len(broad) >= BROAD_EXCEPT_THRESHOLD:
|
||||
findings.append(
|
||||
Signal(
|
||||
"GENERIC_EXCEPTIONS",
|
||||
f"{len(broad)} undifferentiated except Exception handlers",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
1,
|
||||
f"{len(broad)} broad handlers",
|
||||
)
|
||||
)
|
||||
if context.language == "html":
|
||||
inline_styles = INLINE_STYLE_PATTERN.findall(context.text)
|
||||
if len(inline_styles) >= INLINE_STYLE_THRESHOLD:
|
||||
findings.append(
|
||||
Signal("INLINE_STYLES", f"{len(inline_styles)} inline style attributes", SEVERITY_WEAK, AXIS_QUALITY, 1.0, 1, f"{len(inline_styles)} inline styles")
|
||||
)
|
||||
return findings
|
||||
191
devplacepy/services/jobs/isslop/analysis/signals/llmdefaults.py
Normal file
191
devplacepy/services/jobs/isslop/analysis/signals/llmdefaults.py
Normal file
@ -0,0 +1,191 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_ORIGIN,
|
||||
AXIS_QUALITY,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_STRONG,
|
||||
FileContext,
|
||||
Signal,
|
||||
)
|
||||
|
||||
CODE_LANGUAGES_FOR_DEFAULTS: frozenset[str] = frozenset(
|
||||
{"python", "javascript", "typescript", "php", "go", "java", "ruby", "csharp", "yaml", "json", "html"}
|
||||
)
|
||||
|
||||
DEFAULT_SECRET_PATTERN = re.compile(
|
||||
r"(secret|jwt[_-]?secret|secret[_-]?key|signing[_-]?key|session[_-]?secret)\w*\s*[:=]\s*"
|
||||
r"[\"'](your[_-]secret[_-]key(?:[_-]here)?|your[_-]super[_-]secret|supersecret\w*|super[_-]secret"
|
||||
r"|my[_-]?secret(?:[_-]?key)?|secret(?:123)?|secret[_-]key|change[_-]?(this|me)\w*|keyboard cat)[\"']",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
WILDCARD_CORS_CREDENTIALS_PATTERN = re.compile(
|
||||
r"allow_origins\s*=\s*\[\s*[\"']\*[\"']\s*\][\s\S]{0,200}allow_credentials\s*=\s*True"
|
||||
r"|allow_credentials\s*=\s*True[\s\S]{0,200}allow_origins\s*=\s*\[\s*[\"']\*[\"']\s*\]"
|
||||
r"|credentials\s*:\s*true[\s\S]{0,120}origin\s*:\s*[\"']\*[\"']"
|
||||
r"|origin\s*:\s*[\"']\*[\"'][\s\S]{0,120}credentials\s*:\s*true",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
WILDCARD_CORS_PATTERN = re.compile(
|
||||
r"allow_origins\s*=\s*\[\s*[\"']\*[\"']\s*\]"
|
||||
r"|Access-Control-Allow-Origin[\"']?\s*[,:]\s*[\"']\*[\"']"
|
||||
r"|app\.use\(\s*cors\(\s*\)\s*\)"
|
||||
r"|origin\s*:\s*[\"']\*[\"']",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
DEBUG_ENABLED_PATTERN = re.compile(
|
||||
r"\.run\([^)]*debug\s*=\s*True|^\s*DEBUG\s*=\s*True\s*$|debug\s*:\s*true\s*,?\s*//|APP_DEBUG=true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
SEED_CREDENTIAL_PATTERN = re.compile(
|
||||
r"[\"'](admin123|password123|test123|admin@example\.com|test@test\.com|admin@admin\.com|letmein|qwerty123)[\"']",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
DEFAULT_DATABASE_PATTERN = re.compile(
|
||||
r"mongodb(\+srv)?://localhost(:27017)?/(mydb|myapp|test|mydatabase|app)"
|
||||
r"|(postgres(ql)?|mysql)://(user|root|admin|postgres):(password|pass|root|admin|secret)@localhost",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
TUTORIAL_SERVER_PATTERN = re.compile(
|
||||
r"console\.log\(\s*[\"'`]Server (is )?(running|listening|started) (on|at) (port |http)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
APP_NAME_DEFAULT_PATTERN = re.compile(
|
||||
r"[\"'](my-?app|my-?project|test-?app|awesome-?project|my-?website)[\"']\s*[,:]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
MAX_FINDINGS: int = 10
|
||||
|
||||
|
||||
def _line_of(text: str, position: int) -> int:
|
||||
return text.count("\n", 0, position) + 1
|
||||
|
||||
|
||||
def detect_llm_defaults(context: FileContext) -> list[Signal]:
|
||||
if context.language not in CODE_LANGUAGES_FOR_DEFAULTS:
|
||||
return []
|
||||
findings: list[Signal] = []
|
||||
text = context.text
|
||||
|
||||
for match in DEFAULT_SECRET_PATTERN.finditer(text):
|
||||
findings.append(
|
||||
Signal(
|
||||
"LLM_DEFAULT_SECRET",
|
||||
f"Stock LLM placeholder secret '{match.group(2)}' left in configuration",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_QUALITY,
|
||||
5.0,
|
||||
_line_of(text, match.start()),
|
||||
match.group(0)[:120],
|
||||
)
|
||||
)
|
||||
|
||||
credentials_combo = WILDCARD_CORS_CREDENTIALS_PATTERN.search(text)
|
||||
if credentials_combo:
|
||||
findings.append(
|
||||
Signal(
|
||||
"WILDCARD_CORS_CREDENTIALS",
|
||||
"Wildcard CORS origin combined with credentials, the canonical LLM scaffold vulnerability",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_QUALITY,
|
||||
4.0,
|
||||
_line_of(text, credentials_combo.start()),
|
||||
credentials_combo.group(0)[:120],
|
||||
)
|
||||
)
|
||||
else:
|
||||
wildcard = WILDCARD_CORS_PATTERN.search(text)
|
||||
if wildcard:
|
||||
findings.append(
|
||||
Signal(
|
||||
"WILDCARD_CORS",
|
||||
"Wildcard CORS configuration, the default LLM scaffold",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
2.0,
|
||||
_line_of(text, wildcard.start()),
|
||||
wildcard.group(0)[:120],
|
||||
)
|
||||
)
|
||||
|
||||
debug = DEBUG_ENABLED_PATTERN.search(text)
|
||||
if debug:
|
||||
findings.append(
|
||||
Signal(
|
||||
"DEBUG_ENABLED",
|
||||
"Debug mode committed enabled, the tutorial default",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
2.0,
|
||||
_line_of(text, debug.start()),
|
||||
debug.group(0).strip()[:120],
|
||||
)
|
||||
)
|
||||
|
||||
for match in SEED_CREDENTIAL_PATTERN.finditer(text):
|
||||
findings.append(
|
||||
Signal(
|
||||
"SEED_CREDENTIALS",
|
||||
f"Stock demo credential {match.group(1)} in source",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
3.0,
|
||||
_line_of(text, match.start()),
|
||||
match.group(0)[:120],
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
database = DEFAULT_DATABASE_PATTERN.search(text)
|
||||
if database:
|
||||
findings.append(
|
||||
Signal(
|
||||
"DEFAULT_DATABASE_URL",
|
||||
"Tutorial-default database connection string",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
_line_of(text, database.start()),
|
||||
database.group(0)[:120],
|
||||
)
|
||||
)
|
||||
|
||||
tutorial = TUTORIAL_SERVER_PATTERN.search(text)
|
||||
if tutorial:
|
||||
findings.append(
|
||||
Signal(
|
||||
"TUTORIAL_SERVER_LOG",
|
||||
"Tutorial-style server startup console.log",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
_line_of(text, tutorial.start()),
|
||||
tutorial.group(0)[:120],
|
||||
)
|
||||
)
|
||||
|
||||
app_name = APP_NAME_DEFAULT_PATTERN.search(text)
|
||||
if app_name:
|
||||
findings.append(
|
||||
Signal(
|
||||
"DEFAULT_PROJECT_NAME",
|
||||
f"Default scaffold project name {app_name.group(1)}",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
_line_of(text, app_name.start()),
|
||||
app_name.group(0)[:80],
|
||||
)
|
||||
)
|
||||
|
||||
return findings[:MAX_FINDINGS]
|
||||
115
devplacepy/services/jobs/isslop/analysis/signals/naming.py
Normal file
115
devplacepy/services/jobs/isslop/analysis/signals/naming.py
Normal file
@ -0,0 +1,115 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_ORIGIN,
|
||||
AXIS_QUALITY,
|
||||
SEVERITY_MEDIUM,
|
||||
FileContext,
|
||||
Signal,
|
||||
)
|
||||
|
||||
DECLARATION_PATTERNS: dict[str, tuple[re.Pattern[str], ...]] = {
|
||||
"python": (
|
||||
re.compile(r"^\s*(?:async\s+)?def\s+(\w+)"),
|
||||
re.compile(r"^\s*class\s+(\w+)"),
|
||||
re.compile(r"^\s*([a-zA-Z_]\w*)\s*(?::[^=]+)?=\s*[^=]"),
|
||||
),
|
||||
"javascript": (
|
||||
re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)"),
|
||||
re.compile(r"^\s*(?:export\s+)?class\s+(\w+)"),
|
||||
re.compile(r"^\s*(?:const|let|var)\s+(\w+)"),
|
||||
),
|
||||
}
|
||||
DECLARATION_PATTERNS["typescript"] = DECLARATION_PATTERNS["javascript"]
|
||||
|
||||
GENERIC_IDENTIFIERS: frozenset[str] = frozenset(
|
||||
{
|
||||
"data", "result", "res", "temp", "tmp", "item", "items", "value", "values", "val",
|
||||
"obj", "object", "thing", "stuff", "info", "foo", "bar", "baz", "output", "input",
|
||||
"myfunction", "myvar", "mydata", "example", "processdata", "dosomething", "handledata",
|
||||
"handleclick", "handlesubmit", "handlechange", "mycomponent", "untitled", "test1", "func",
|
||||
"arr", "lst", "dct", "retval", "resp", "ret",
|
||||
}
|
||||
)
|
||||
|
||||
VERBOSE_MIN_LENGTH: int = 30
|
||||
VERBOSE_MIN_WORDS: int = 5
|
||||
GENERIC_DENSITY_THRESHOLD: float = 0.30
|
||||
MIN_DECLARATIONS: int = 5
|
||||
|
||||
|
||||
def _split_words(identifier: str) -> list[str]:
|
||||
parts = re.split(r"_+", identifier)
|
||||
words: list[str] = []
|
||||
for part in parts:
|
||||
words.extend(re.findall(r"[A-Z]?[a-z0-9]+|[A-Z]+(?![a-z])", part))
|
||||
return [word for word in words if word]
|
||||
|
||||
|
||||
def detect_naming(context: FileContext) -> list[Signal]:
|
||||
patterns = DECLARATION_PATTERNS.get(context.language)
|
||||
if not patterns:
|
||||
return []
|
||||
declarations: list[tuple[int, str]] = []
|
||||
for number, line in enumerate(context.lines, start=1):
|
||||
for pattern in patterns:
|
||||
match = pattern.match(line)
|
||||
if match:
|
||||
name = next(group for group in match.groups() if group)
|
||||
declarations.append((number, name))
|
||||
break
|
||||
if len(declarations) < MIN_DECLARATIONS:
|
||||
return []
|
||||
findings: list[Signal] = []
|
||||
generic = [(number, name) for number, name in declarations if name.lower().replace("_", "") in GENERIC_IDENTIFIERS]
|
||||
density = len(generic) / len(declarations)
|
||||
if density >= GENERIC_DENSITY_THRESHOLD:
|
||||
number, name = generic[0]
|
||||
findings.append(
|
||||
Signal(
|
||||
"GENERIC_NAMING",
|
||||
f"Generic identifiers in {density:.0%} of {len(declarations)} declarations",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
2.0,
|
||||
number,
|
||||
name,
|
||||
)
|
||||
)
|
||||
verbose = [
|
||||
(number, name)
|
||||
for number, name in declarations
|
||||
if len(name) >= VERBOSE_MIN_LENGTH and len(_split_words(name)) >= VERBOSE_MIN_WORDS
|
||||
]
|
||||
if verbose:
|
||||
number, name = verbose[0]
|
||||
findings.append(
|
||||
Signal(
|
||||
"TEXTBOOK_NAMING",
|
||||
"Over-verbose textbook identifier",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
number,
|
||||
name,
|
||||
)
|
||||
)
|
||||
snake = sum(1 for _, name in declarations if "_" in name and name.lower() == name)
|
||||
camel = sum(1 for _, name in declarations if "_" not in name and name.lower() != name and name[0].islower())
|
||||
total = len(declarations)
|
||||
if snake >= 3 and camel >= 3 and snake / total >= 0.25 and camel / total >= 0.25:
|
||||
findings.append(
|
||||
Signal(
|
||||
"NAMING_MIXED",
|
||||
f"Mixed snake_case ({snake}) and camelCase ({camel}) declarations",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
declarations[0][0],
|
||||
f"{snake} snake_case vs {camel} camelCase",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
149
devplacepy/services/jobs/isslop/analysis/signals/scaffolds.py
Normal file
149
devplacepy/services/jobs/isslop/analysis/signals/scaffolds.py
Normal file
@ -0,0 +1,149 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_ORIGIN,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_STRONG,
|
||||
SEVERITY_WEAK,
|
||||
FileContext,
|
||||
Signal,
|
||||
)
|
||||
|
||||
HTML_SCAFFOLD_PATTERNS: tuple[tuple[re.Pattern[str], str, str], ...] = (
|
||||
(re.compile(r"Generated by create next app", re.IGNORECASE), "Untouched create-next-app metadata", SEVERITY_STRONG),
|
||||
(re.compile(r"<title>\s*Create Next App\s*</title>", re.IGNORECASE), "Default Create Next App title", SEVERITY_STRONG),
|
||||
(re.compile(r"<title>\s*(Vite \+ React|Vite App|React App)\s*</title>", re.IGNORECASE), "Default Vite/CRA title", SEVERITY_STRONG),
|
||||
(re.compile(r"You need to enable JavaScript to run this app", re.IGNORECASE), "Create React App noscript boilerplate", SEVERITY_STRONG),
|
||||
(re.compile(r"/vite\.svg|src=[\"']/?vite\.svg", re.IGNORECASE), "Default Vite logo asset", SEVERITY_MEDIUM),
|
||||
(re.compile(r"<div\s+id=[\"']root[\"']\s*>\s*</div>", re.IGNORECASE), "Empty CRA/Vite root mount div", SEVERITY_MEDIUM),
|
||||
(re.compile(r"<div\s+id=[\"']__next[\"']", re.IGNORECASE), "Next.js hydration root", SEVERITY_WEAK),
|
||||
(re.compile(r"src=[\"']/src/main\.(jsx|tsx)[\"']", re.IGNORECASE), "Default Vite entry module", SEVERITY_MEDIUM),
|
||||
(re.compile(r"Edit <code>src/App", re.IGNORECASE), "Untouched CRA starter copy", SEVERITY_STRONG),
|
||||
(re.compile(r"Save to reload", re.IGNORECASE), "Untouched Vite/CRA starter copy", SEVERITY_STRONG),
|
||||
(re.compile(r"content=[\"']Web site created using create-react-app", re.IGNORECASE), "CRA description meta", SEVERITY_STRONG),
|
||||
(re.compile(r"%PUBLIC_URL%", re.IGNORECASE), "Unrendered CRA template token", SEVERITY_STRONG),
|
||||
(re.compile(r"__NEXT_DATA__|self\.__next_f", re.IGNORECASE), "Next.js runtime payload", SEVERITY_WEAK),
|
||||
(re.compile(r"<meta[^>]+name=[\"']generator[\"'][^>]+(gatsby|hugo|jekyll|astro|next\.js|nuxt|docusaurus)", re.IGNORECASE), "Static-site generator meta", SEVERITY_WEAK),
|
||||
)
|
||||
|
||||
PY_DOCSTRING_TRIVIAL_PATTERN = re.compile(
|
||||
r"def\s+\w+\([^)]*\)\s*(?:->[^\n:]+)?:\s*\n\s*(?:\"\"\"|''')[^\n]{5,80}(?:\"\"\"|''')\s*\n\s*return\b",
|
||||
re.MULTILINE,
|
||||
)
|
||||
PY_VERBOSE_NAME_PATTERN = re.compile(r"\b[a-z]+(?:_[a-z]+){4,}\b\s*=")
|
||||
PY_ERROR_PRINT_PATTERN = re.compile(
|
||||
r"except\s+\w*(?:Exception|Error)?\s*(?:as\s+(\w+))?\s*:\s*\n\s*print\(\s*f?[\"'](?:an?\s+)?error", re.IGNORECASE
|
||||
)
|
||||
PY_MAIN_GUARD_PATTERN = re.compile(r"^if\s+__name__\s*==\s*[\"']__main__[\"']\s*:", re.MULTILINE)
|
||||
PY_TYPE_HINT_PATTERN = re.compile(r"def\s+\w+\([^)]*:\s*\w+[^)]*\)\s*->\s*\w+")
|
||||
PY_FULL_DOCSTRING_PATTERN = re.compile(r"def\s+\w+[^:]*:\s*\n\s*(?:\"\"\"|''')")
|
||||
PY_LOGGING_FSTRING_PATTERN = re.compile(r"logging\.(info|debug|warning|error)\(\s*f[\"']")
|
||||
|
||||
VERBOSE_NAME_THRESHOLD: int = 2
|
||||
TRIVIAL_DOCSTRING_THRESHOLD: int = 2
|
||||
TEXTBOOK_MIN_FUNCTIONS: int = 5
|
||||
|
||||
|
||||
def _line_of(text: str, position: int) -> int:
|
||||
return text.count("\n", 0, position) + 1
|
||||
|
||||
|
||||
def _html_scaffold_signals(context: FileContext) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
text = context.text
|
||||
for pattern, title, severity in HTML_SCAFFOLD_PATTERNS:
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
weight = 4.0 if severity == SEVERITY_STRONG else (2.0 if severity == SEVERITY_MEDIUM else 0.5)
|
||||
findings.append(
|
||||
Signal("FRAMEWORK_SCAFFOLD", f"Framework scaffold artifact: {title}", severity, AXIS_ORIGIN, weight, _line_of(text, match.start()), match.group(0)[:100])
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def _python_structure_signals(context: FileContext) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
text = context.text
|
||||
metrics = context.metrics
|
||||
|
||||
trivial = PY_DOCSTRING_TRIVIAL_PATTERN.findall(text)
|
||||
if len(trivial) >= TRIVIAL_DOCSTRING_THRESHOLD:
|
||||
match = PY_DOCSTRING_TRIVIAL_PATTERN.search(text)
|
||||
findings.append(
|
||||
Signal(
|
||||
"TRIVIAL_DOCSTRINGS",
|
||||
f"{len(trivial)} trivial functions with textbook docstrings",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
_line_of(text, match.start()) if match else 1,
|
||||
"over-documented one-line functions",
|
||||
)
|
||||
)
|
||||
|
||||
verbose = PY_VERBOSE_NAME_PATTERN.findall(text)
|
||||
if len(verbose) >= VERBOSE_NAME_THRESHOLD:
|
||||
match = PY_VERBOSE_NAME_PATTERN.search(text)
|
||||
findings.append(
|
||||
Signal(
|
||||
"VERBOSE_TEXTBOOK_NAMES",
|
||||
f"{len(verbose)} extremely verbose textbook variable names",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
_line_of(text, match.start()) if match else 1,
|
||||
match.group(0).strip()[:80] if match else "",
|
||||
)
|
||||
)
|
||||
|
||||
error_print = PY_ERROR_PRINT_PATTERN.search(text)
|
||||
if error_print:
|
||||
findings.append(
|
||||
Signal(
|
||||
"GENERIC_ERROR_PRINT",
|
||||
"Textbook 'except: print(f\"Error: {e}\")' handling",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
_line_of(text, error_print.start()),
|
||||
error_print.group(0).strip()[:80],
|
||||
)
|
||||
)
|
||||
|
||||
if metrics is not None and metrics.function_count >= TEXTBOOK_MIN_FUNCTIONS:
|
||||
typed = len(PY_TYPE_HINT_PATTERN.findall(text))
|
||||
documented = len(PY_FULL_DOCSTRING_PATTERN.findall(text))
|
||||
has_main_guard = bool(PY_MAIN_GUARD_PATTERN.search(text))
|
||||
logging_fstring = bool(PY_LOGGING_FSTRING_PATTERN.search(text))
|
||||
markers = sum(
|
||||
[
|
||||
typed >= max(3, metrics.function_count // 2),
|
||||
documented >= metrics.function_count,
|
||||
has_main_guard,
|
||||
logging_fstring,
|
||||
]
|
||||
)
|
||||
if markers >= 3:
|
||||
findings.append(
|
||||
Signal(
|
||||
"TEXTBOOK_STRUCTURE",
|
||||
"Uniformly textbook structure: full type hints, docstring on every function, main guard",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
3.0,
|
||||
1,
|
||||
f"typed={typed}, docstrings={documented}, main_guard={has_main_guard}",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def detect_scaffolds(context: FileContext) -> list[Signal]:
|
||||
if context.language == "html":
|
||||
return _html_scaffold_signals(context)
|
||||
if context.language == "python":
|
||||
return _python_structure_signals(context)
|
||||
return []
|
||||
98
devplacepy/services/jobs/isslop/analysis/signals/security.py
Normal file
98
devplacepy/services/jobs/isslop/analysis/signals/security.py
Normal file
@ -0,0 +1,98 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.metrics import shannon_entropy
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_QUALITY,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_STRONG,
|
||||
FileContext,
|
||||
Signal,
|
||||
)
|
||||
|
||||
SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "AWS access key"),
|
||||
(re.compile(r"\bghp_[A-Za-z0-9]{36,}\b"), "GitHub personal token"),
|
||||
(re.compile(r"\bgithub_pat_[A-Za-z0-9_]{22,}\b"), "GitHub fine-grained token"),
|
||||
(re.compile(r"\bsk_live_[A-Za-z0-9]{16,}\b"), "Stripe live key"),
|
||||
(re.compile(r"\bsk-[A-Za-z0-9]{40,}\b"), "API secret key"),
|
||||
(re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b"), "Google API key"),
|
||||
(re.compile(r"\bxox[baprs]-[A-Za-z0-9\-]{10,}\b"), "Slack token"),
|
||||
(re.compile(r"-----BEGIN[A-Z ]*PRIVATE KEY-----"), "Private key material"),
|
||||
(re.compile(r"\b(?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis|amqp)://\w+:[^@/\s]{4,}@"), "Credentials in connection string"),
|
||||
)
|
||||
|
||||
ENTROPY_ASSIGNMENT_PATTERN = re.compile(
|
||||
r"(secret|token|passwd|password|api[_-]?key|auth[_-]?key|private[_-]?key)\w*\s*[:=]\s*[\"']([^\"'\s]{16,})[\"']",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
ENTROPY_THRESHOLD: float = 3.5
|
||||
|
||||
ALLOWLIST_MARKERS: tuple[str, ...] = (
|
||||
"example", "your_", "your-", "changeme", "change_me", "placeholder", "xxxx", "dummy",
|
||||
"sample", "<", "${", "{{", "todo", "insert", "test", "fake", "redacted", "akiaiosfodnn7example",
|
||||
)
|
||||
ALLOWLIST_PATH_MARKERS: tuple[str, ...] = (".example", "fixture", "mock", "sample", "/docs/", "/test", "_test", "spec.")
|
||||
|
||||
INJECTION_PATTERNS: tuple[tuple[re.Pattern[str], str, str], ...] = (
|
||||
(
|
||||
re.compile(r"[\"'](?:SELECT|INSERT|UPDATE|DELETE|DROP)\b[^\"']*[\"']\s*\+", re.IGNORECASE),
|
||||
"SQL built by string concatenation",
|
||||
SEVERITY_STRONG,
|
||||
),
|
||||
(
|
||||
re.compile(r"f[\"'](?:[^\"']*\b(?:SELECT|INSERT|UPDATE|DELETE)\b[^\"']*\{)", re.IGNORECASE),
|
||||
"SQL built with f-string interpolation",
|
||||
SEVERITY_STRONG,
|
||||
),
|
||||
(re.compile(r"\beval\s*\(\s*[a-zA-Z_$][\w$.]*\s*[,)]"), "eval() on a variable", SEVERITY_STRONG),
|
||||
(re.compile(r"dangerouslySetInnerHTML"), "dangerouslySetInnerHTML usage", SEVERITY_STRONG),
|
||||
(re.compile(r"\.innerHTML\s*=\s*(?![\"'`][^$])"), "innerHTML assigned from dynamic value", SEVERITY_MEDIUM),
|
||||
(re.compile(r"document\.write\s*\("), "document.write usage", SEVERITY_MEDIUM),
|
||||
(re.compile(r"child_process[.\w]*\.exec(?:Sync)?\s*\(\s*[`\"'][^`\"']*[$+]"), "Shell exec with interpolation", SEVERITY_STRONG),
|
||||
(re.compile(r"os\.system\s*\(\s*f?[\"'][^\"']*[{+]"), "os.system with interpolation", SEVERITY_STRONG),
|
||||
(re.compile(r"subprocess\.\w+\([^)]*shell\s*=\s*True[^)]*[+{]"), "subprocess shell=True with dynamic input", SEVERITY_STRONG),
|
||||
)
|
||||
|
||||
|
||||
def _is_allowlisted(line: str, relative_path: str) -> bool:
|
||||
lowered = line.lower()
|
||||
if any(marker in lowered for marker in ALLOWLIST_MARKERS):
|
||||
return True
|
||||
lowered_path = relative_path.lower()
|
||||
return any(marker in lowered_path for marker in ALLOWLIST_PATH_MARKERS)
|
||||
|
||||
|
||||
def detect_security(context: FileContext) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
for number, line in enumerate(context.lines, start=1):
|
||||
for pattern, title in SECRET_PATTERNS:
|
||||
if pattern.search(line) and not _is_allowlisted(line, context.relative):
|
||||
findings.append(
|
||||
Signal("HARDCODED_SECRET", f"Hardcoded secret: {title}", SEVERITY_STRONG, AXIS_QUALITY, 5.0, number, line.strip()[:120])
|
||||
)
|
||||
break
|
||||
entropy_match = ENTROPY_ASSIGNMENT_PATTERN.search(line)
|
||||
if entropy_match and not _is_allowlisted(line, context.relative):
|
||||
candidate = entropy_match.group(2)
|
||||
if shannon_entropy(candidate) >= ENTROPY_THRESHOLD:
|
||||
findings.append(
|
||||
Signal(
|
||||
"HARDCODED_SECRET",
|
||||
f"High-entropy credential assigned to '{entropy_match.group(1)}'",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_QUALITY,
|
||||
5.0,
|
||||
number,
|
||||
line.strip()[:120],
|
||||
)
|
||||
)
|
||||
for pattern, title, severity in INJECTION_PATTERNS:
|
||||
if pattern.search(line):
|
||||
findings.append(
|
||||
Signal("INJECTION_RISK", title, severity, AXIS_QUALITY, 5.0, number, line.strip()[:120])
|
||||
)
|
||||
break
|
||||
return findings[:12]
|
||||
140
devplacepy/services/jobs/isslop/analysis/signals/structure.py
Normal file
140
devplacepy/services/jobs/isslop/analysis/signals/structure.py
Normal file
@ -0,0 +1,140 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_ORIGIN,
|
||||
AXIS_QUALITY,
|
||||
SEVERITY_MEDIUM,
|
||||
FileContext,
|
||||
Signal,
|
||||
)
|
||||
|
||||
DEAD_CODE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(re.compile(r"\bif\s*\(\s*false\s*\)"), "if (false) branch"),
|
||||
(re.compile(r"^\s*if\s+False\s*:"), "if False: branch"),
|
||||
(re.compile(r"^\s*if\s+0\s*:"), "if 0: branch"),
|
||||
(re.compile(r"\bwhile\s*\(\s*false\s*\)"), "while (false) loop"),
|
||||
)
|
||||
|
||||
ABSTRACTION_SUFFIX_PATTERN = re.compile(
|
||||
r"class\s+\w*(Factory|Manager|Provider|Strategy|Handler|Wrapper|Helper|Service|Orchestrator|Coordinator|Impl)\b"
|
||||
)
|
||||
CLASS_PATTERN = re.compile(r"^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)", re.MULTILINE)
|
||||
|
||||
UNIFORM_INDENT_DEVIATION: float = 0.45
|
||||
UNIFORM_MIN_SLOC: int = 50
|
||||
GOD_MODULE_SLOC: int = 600
|
||||
GOD_MODULE_IMPORTS: int = 20
|
||||
DUPLICATION_THRESHOLD: float = 0.08
|
||||
LONG_FUNCTION_LINES: int = 120
|
||||
COMPLEXITY_THRESHOLD: int = 15
|
||||
ABSTRACTION_DENSITY: float = 0.5
|
||||
ABSTRACTION_MIN_CLASSES: int = 3
|
||||
|
||||
|
||||
def detect_structure(context: FileContext) -> list[Signal]:
|
||||
metrics = context.metrics
|
||||
if metrics is None:
|
||||
return []
|
||||
findings: list[Signal] = []
|
||||
if metrics.sloc >= UNIFORM_MIN_SLOC and metrics.indent_variance < UNIFORM_INDENT_DEVIATION:
|
||||
findings.append(
|
||||
Signal(
|
||||
"MACHINE_REGULARITY",
|
||||
f"Unnaturally uniform whitespace (indent deviation {metrics.indent_variance:.2f})",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
1,
|
||||
f"sloc={metrics.sloc}",
|
||||
)
|
||||
)
|
||||
if (
|
||||
context.language == "python"
|
||||
and metrics.function_count >= 4
|
||||
and metrics.docstring_count >= metrics.function_count
|
||||
):
|
||||
findings.append(
|
||||
Signal(
|
||||
"DOCSTRING_UNIFORMITY",
|
||||
f"Every one of {metrics.function_count} functions carries a docstring template",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
1,
|
||||
f"{metrics.docstring_count}/{metrics.function_count} docstrings",
|
||||
)
|
||||
)
|
||||
if metrics.sloc > GOD_MODULE_SLOC and metrics.import_count > GOD_MODULE_IMPORTS:
|
||||
findings.append(
|
||||
Signal(
|
||||
"GOD_MODULE",
|
||||
f"Module mixes many concerns ({metrics.sloc} SLOC, {metrics.import_count} imports)",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
3.0,
|
||||
1,
|
||||
f"sloc={metrics.sloc} imports={metrics.import_count}",
|
||||
)
|
||||
)
|
||||
if metrics.duplication_ratio > DUPLICATION_THRESHOLD:
|
||||
findings.append(
|
||||
Signal(
|
||||
"DUPLICATED_LOGIC",
|
||||
f"Duplicated block ratio {metrics.duplication_ratio:.1%}",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
3.0,
|
||||
1,
|
||||
f"windows duplicated: {metrics.duplication_ratio:.1%}",
|
||||
)
|
||||
)
|
||||
if metrics.max_function_length > LONG_FUNCTION_LINES:
|
||||
findings.append(
|
||||
Signal(
|
||||
"LONG_FUNCTION",
|
||||
f"Function of {metrics.max_function_length} lines",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
1,
|
||||
f"max function length {metrics.max_function_length}",
|
||||
)
|
||||
)
|
||||
if metrics.cyclomatic_max_estimate > COMPLEXITY_THRESHOLD:
|
||||
findings.append(
|
||||
Signal(
|
||||
"HIGH_COMPLEXITY",
|
||||
f"Estimated cyclomatic complexity {metrics.cyclomatic_max_estimate} per function",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
2.0,
|
||||
1,
|
||||
f"complexity estimate {metrics.cyclomatic_max_estimate}",
|
||||
)
|
||||
)
|
||||
for number, line in enumerate(context.lines, start=1):
|
||||
for pattern, title in DEAD_CODE_PATTERNS:
|
||||
if pattern.search(line):
|
||||
findings.append(
|
||||
Signal("DEAD_DEFENSIVE_CODE", f"Dead code: {title}", SEVERITY_MEDIUM, AXIS_QUALITY, 2.0, number, line.strip())
|
||||
)
|
||||
break
|
||||
classes = CLASS_PATTERN.findall(context.text)
|
||||
if len(classes) >= ABSTRACTION_MIN_CLASSES:
|
||||
pattern_classes = ABSTRACTION_SUFFIX_PATTERN.findall(context.text)
|
||||
if len(pattern_classes) / len(classes) >= ABSTRACTION_DENSITY:
|
||||
findings.append(
|
||||
Signal(
|
||||
"OVER_ABSTRACTION",
|
||||
f"{len(pattern_classes)} of {len(classes)} classes are pattern-suffixed abstractions",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
3.0,
|
||||
1,
|
||||
", ".join(pattern_classes[:5]),
|
||||
)
|
||||
)
|
||||
return findings
|
||||
198
devplacepy/services/jobs/isslop/analysis/signals/textual.py
Normal file
198
devplacepy/services/jobs/isslop/analysis/signals/textual.py
Normal file
@ -0,0 +1,198 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_ORIGIN,
|
||||
AXIS_QUALITY,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_STRONG,
|
||||
SEVERITY_WEAK,
|
||||
FileContext,
|
||||
Signal,
|
||||
)
|
||||
|
||||
PLACEHOLDER_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(re.compile(r"your (code|logic|implementation) here", re.IGNORECASE), "Placeholder body left in source"),
|
||||
(re.compile(r"\.\.\. ?rest of (the )?code", re.IGNORECASE), "Truncated generation marker"),
|
||||
(re.compile(r"\.\.\. ?(existing|other|previous) code", re.IGNORECASE), "Elided-code marker"),
|
||||
(re.compile(r"/\*\s*implementation\s*\*/", re.IGNORECASE), "Empty implementation stub"),
|
||||
(re.compile(r"TODO:? implement( this)?\b", re.IGNORECASE), "Unimplemented TODO stub"),
|
||||
(re.compile(r"TODO:? add (logic|implementation|code)", re.IGNORECASE), "Unimplemented TODO stub"),
|
||||
(re.compile(r"<!--\s*add content here\s*-->", re.IGNORECASE), "Placeholder HTML content"),
|
||||
(re.compile(r"rest omitted", re.IGNORECASE), "Truncated generation marker"),
|
||||
)
|
||||
|
||||
PROMPT_LEAK_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"as an ai (language )?model", re.IGNORECASE),
|
||||
re.compile(r"i (cannot|can't) (assist|help) with", re.IGNORECASE),
|
||||
re.compile(r"here('|’)?s the (updated|complete|revised|full|corrected) code", re.IGNORECASE),
|
||||
re.compile(r"\bcertainly!\s", re.IGNORECASE),
|
||||
re.compile(r"sure, here('s| is)", re.IGNORECASE),
|
||||
re.compile(r"i hope this helps", re.IGNORECASE),
|
||||
re.compile(r"let me know if (you|there)", re.IGNORECASE),
|
||||
re.compile(r"feel free to (adjust|modify|change|customize)", re.IGNORECASE),
|
||||
re.compile(r"note that this is a (simplified|basic) (example|implementation|version)", re.IGNORECASE),
|
||||
re.compile(r"in a (real|production)([- ]world)? (application|scenario|environment|setting)", re.IGNORECASE),
|
||||
re.compile(r"this is a placeholder", re.IGNORECASE),
|
||||
re.compile(r"replace .{1,40} with your (actual|own)", re.IGNORECASE),
|
||||
)
|
||||
|
||||
AI_SIGNATURE_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"generated (by|with) (chatgpt|copilot|claude|cursor|gemini|codeium|v0|bolt|ai)", re.IGNORECASE),
|
||||
re.compile(r"co-authored-by:\s*(claude|copilot)", re.IGNORECASE),
|
||||
re.compile(r"\U0001F916 generated with"),
|
||||
re.compile(r"created with the help of ai", re.IGNORECASE),
|
||||
re.compile(r"\bclaude code\b", re.IGNORECASE),
|
||||
re.compile(r"powered by (chatgpt|gpt-4|claude|gemini)", re.IGNORECASE),
|
||||
)
|
||||
|
||||
EMOJI_PATTERN = re.compile(
|
||||
"[\U0001F300-\U0001FAFF☀-➿⬀-⯿✅❌✨❗⭐]"
|
||||
)
|
||||
|
||||
ENTHUSIASM_PATTERN = re.compile(
|
||||
r"blazingly fast|seamlessly|robust and scalable|cutting[- ]edge|revolutioniz|effortlessly|supercharge|game[- ]chang",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
LLM_LEXICON_PATTERN = re.compile(
|
||||
r"\b(delve|delving|delves|showcas(?:e|es|ing)|pivotal|intricate|meticulous(?:ly)?|realm"
|
||||
r"|bolster(?:s|ing)?|garnered|underpins|seamless(?:ly)?|leverag(?:e|es|ing)|streamlin(?:e|es|ing)"
|
||||
r"|elevat(?:e|es|ing)|holistic|robust and|comprehensive suite|game[- ]chang(?:er|ing)"
|
||||
r"|it'?s (?:important|worth) (?:to note|noting)|keep in mind|as you can see|in this example)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
LLM_LEXICON_THRESHOLD: int = 3
|
||||
EM_DASH_THRESHOLD: int = 3
|
||||
|
||||
WORD_PATTERN = re.compile(r"[a-z][a-z0-9]+")
|
||||
NARRATION_MIN_OVERLAP: float = 0.55
|
||||
NARRATION_MIN_TOKENS: int = 2
|
||||
|
||||
|
||||
def _narration_signals(context: FileContext) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
line_lookup = {number: text for number, text in context.comments}
|
||||
for number, comment in context.comments:
|
||||
if number + 1 in line_lookup or number >= len(context.lines):
|
||||
continue
|
||||
comment_tokens = set(WORD_PATTERN.findall(comment.lower()))
|
||||
if len(comment_tokens) < NARRATION_MIN_TOKENS:
|
||||
continue
|
||||
code_line = context.lines[number].lower()
|
||||
code_tokens = set(WORD_PATTERN.findall(code_line))
|
||||
if not code_tokens:
|
||||
continue
|
||||
overlap = len(comment_tokens & code_tokens) / len(comment_tokens)
|
||||
if overlap >= NARRATION_MIN_OVERLAP:
|
||||
findings.append(
|
||||
Signal(
|
||||
code="COMMENT_NARRATION",
|
||||
title="Comment narrates the next line of code",
|
||||
severity=SEVERITY_MEDIUM,
|
||||
axis=AXIS_ORIGIN,
|
||||
weight=2.0,
|
||||
line=number,
|
||||
evidence=comment,
|
||||
)
|
||||
)
|
||||
return findings[:8]
|
||||
|
||||
|
||||
def detect_textual(context: FileContext) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
for number, raw in enumerate(context.lines, start=1):
|
||||
for pattern, title in PLACEHOLDER_PATTERNS:
|
||||
if pattern.search(raw):
|
||||
findings.append(
|
||||
Signal("PLACEHOLDER_COMMENT", title, SEVERITY_STRONG, AXIS_QUALITY, 4.0, number, raw.strip())
|
||||
)
|
||||
break
|
||||
for pattern in AI_SIGNATURE_PATTERNS:
|
||||
if pattern.search(raw):
|
||||
findings.append(
|
||||
Signal(
|
||||
"AI_SIGNATURE",
|
||||
"AI tool self-attribution signature",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_ORIGIN,
|
||||
4.0,
|
||||
number,
|
||||
raw.strip(),
|
||||
)
|
||||
)
|
||||
break
|
||||
for number, comment in context.comments:
|
||||
for pattern in PROMPT_LEAK_PATTERNS:
|
||||
if pattern.search(comment):
|
||||
findings.append(
|
||||
Signal(
|
||||
"PROMPT_LEAK",
|
||||
"Assistant conversation leakage in committed source",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_QUALITY,
|
||||
4.0,
|
||||
number,
|
||||
comment,
|
||||
)
|
||||
)
|
||||
break
|
||||
emoji_hits = [(number, comment) for number, comment in context.comments if EMOJI_PATTERN.search(comment)]
|
||||
if len(emoji_hits) >= 2:
|
||||
number, comment = emoji_hits[0]
|
||||
findings.append(
|
||||
Signal(
|
||||
"EMOJI_COMMENTS",
|
||||
f"Emoji used in {len(emoji_hits)} comments",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
number,
|
||||
comment,
|
||||
)
|
||||
)
|
||||
enthusiasm_hits = [(number, comment) for number, comment in context.comments if ENTHUSIASM_PATTERN.search(comment)]
|
||||
if enthusiasm_hits:
|
||||
number, comment = enthusiasm_hits[0]
|
||||
findings.append(
|
||||
Signal(
|
||||
"MARKETING_TONE",
|
||||
"Marketing-style enthusiasm in comments",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
number,
|
||||
comment,
|
||||
)
|
||||
)
|
||||
comment_text = " ".join(comment for _, comment in context.comments)
|
||||
lexicon_hits = {hit.lower() if isinstance(hit, str) else hit for hit in LLM_LEXICON_PATTERN.findall(comment_text)}
|
||||
if len(lexicon_hits) >= LLM_LEXICON_THRESHOLD:
|
||||
findings.append(
|
||||
Signal(
|
||||
"LLM_LEXICON",
|
||||
f"{len(lexicon_hits)} distinct LLM signature words in comments",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
context.comments[0][0] if context.comments else 1,
|
||||
", ".join(sorted(str(hit) for hit in lexicon_hits)[:6]),
|
||||
)
|
||||
)
|
||||
em_dashes = comment_text.count("\u2014")
|
||||
if em_dashes >= EM_DASH_THRESHOLD:
|
||||
findings.append(
|
||||
Signal(
|
||||
"EM_DASH_OVERUSE",
|
||||
f"{em_dashes} em dashes in comments, a strong LLM prose habit",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
context.comments[0][0] if context.comments else 1,
|
||||
f"{em_dashes} em dashes",
|
||||
)
|
||||
)
|
||||
findings.extend(_narration_signals(context))
|
||||
return findings
|
||||
201
devplacepy/services/jobs/isslop/analysis/signals/vibeerrors.py
Normal file
201
devplacepy/services/jobs/isslop/analysis/signals/vibeerrors.py
Normal file
@ -0,0 +1,201 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_QUALITY,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_STRONG,
|
||||
SEVERITY_WEAK,
|
||||
FileContext,
|
||||
Signal,
|
||||
)
|
||||
|
||||
JS_LANGUAGES: frozenset[str] = frozenset({"javascript", "typescript"})
|
||||
|
||||
TOKEN_IN_STORAGE_PATTERN = re.compile(
|
||||
r"(?:local|session)Storage\.setItem\(\s*[\"'`][^\"'`]*(token|jwt|auth|access[_-]?token|refresh[_-]?token|api[_-]?key|secret|password|session)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
INSECURE_COOKIE_PATTERN = re.compile(r"(document\.cookie\s*=|res\.cookie\(|response\.set_cookie\(|setCookie\()", re.IGNORECASE)
|
||||
COOKIE_SECURITY_HINT = re.compile(r"httponly|secure|samesite", re.IGNORECASE)
|
||||
|
||||
USEEFFECT_BLOCK_PATTERN = re.compile(r"useEffect\s*\(\s*\(\s*\)\s*=>\s*\{", re.IGNORECASE)
|
||||
EFFECT_RESOURCE_PATTERN = re.compile(r"\b(setInterval|setTimeout|addEventListener|\.subscribe\(|\.on\()", re.IGNORECASE)
|
||||
EFFECT_CLEANUP_PATTERN = re.compile(r"return\s*(\(\s*\)\s*=>|function)")
|
||||
|
||||
CONDITIONAL_HOOK_PATTERN = re.compile(
|
||||
r"^\s*(if|for|while)\s*\([^)]*\)\s*\{[^}]{0,200}\buse(State|Effect|Ref|Memo|Callback|Context|Reducer)\s*\(",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
MIXED_CONTENT_PATTERN = re.compile(r"[\"'(]http://(?!localhost|127\.0\.0\.1|0\.0\.0\.0)[\w.-]+", re.IGNORECASE)
|
||||
JSON_PARSE_UNGUARDED_PATTERN = re.compile(r"JSON\.parse\(\s*(?:await\s+)?\w+(?:\.(?:text|body|data))?\s*\)")
|
||||
|
||||
ENV_ACCESS_PATTERN = re.compile(r"process\.env\.[A-Z0-9_]+")
|
||||
ENV_FALLBACK_HINT = re.compile(r"process\.env\.[A-Z0-9_]+\s*(\|\||\?\?|,|\))")
|
||||
PUBLIC_SECRET_PATTERN = re.compile(
|
||||
r"(NEXT_PUBLIC|VITE|REACT_APP|NUXT_PUBLIC)_[A-Z0-9_]*(SECRET|PRIVATE|SERVICE_ROLE|API_KEY|TOKEN|PASSWORD)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
SUPABASE_SERVICE_ROLE_PATTERN = re.compile(r"service_role|SUPABASE_SERVICE_ROLE_KEY", re.IGNORECASE)
|
||||
|
||||
MISSING_ROUTE_GUARD_PATTERN = re.compile(r"(router\.(get|post|put|delete|patch)|app\.(get|post|put|delete|patch))\s*\(", re.IGNORECASE)
|
||||
|
||||
ENV_DENSITY_THRESHOLD: int = 4
|
||||
MAX_FINDINGS: int = 10
|
||||
|
||||
|
||||
def _line_of(text: str, position: int) -> int:
|
||||
return text.count("\n", 0, position) + 1
|
||||
|
||||
|
||||
def _effect_cleanup_findings(text: str) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
for match in USEEFFECT_BLOCK_PATTERN.finditer(text):
|
||||
start = match.end()
|
||||
depth = 1
|
||||
index = start
|
||||
while index < len(text) and depth > 0:
|
||||
char = text[index]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
index += 1
|
||||
body = text[start:index]
|
||||
if EFFECT_RESOURCE_PATTERN.search(body) and not EFFECT_CLEANUP_PATTERN.search(body):
|
||||
findings.append(
|
||||
Signal(
|
||||
"MISSING_EFFECT_CLEANUP",
|
||||
"useEffect registers a timer or listener without a cleanup return",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
2.0,
|
||||
_line_of(text, match.start()),
|
||||
match.group(0)[:80],
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def detect_vibe_errors(context: FileContext) -> list[Signal]:
|
||||
findings: list[Signal] = []
|
||||
text = context.text
|
||||
language = context.language
|
||||
|
||||
public_secret = PUBLIC_SECRET_PATTERN.search(text)
|
||||
if public_secret:
|
||||
findings.append(
|
||||
Signal(
|
||||
"PUBLIC_ENV_SECRET",
|
||||
"Secret exposed through a client-side public env variable",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_QUALITY,
|
||||
5.0,
|
||||
_line_of(text, public_secret.start()),
|
||||
public_secret.group(0)[:100],
|
||||
)
|
||||
)
|
||||
|
||||
if language in JS_LANGUAGES or language == "html":
|
||||
token_storage = TOKEN_IN_STORAGE_PATTERN.search(text)
|
||||
if token_storage:
|
||||
findings.append(
|
||||
Signal(
|
||||
"TOKEN_IN_WEB_STORAGE",
|
||||
"Auth token stored in localStorage or sessionStorage, exposed to XSS",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_QUALITY,
|
||||
4.0,
|
||||
_line_of(text, token_storage.start()),
|
||||
token_storage.group(0)[:100],
|
||||
)
|
||||
)
|
||||
cookie_match = INSECURE_COOKIE_PATTERN.search(text)
|
||||
if cookie_match:
|
||||
window = text[cookie_match.start(): cookie_match.start() + 300]
|
||||
if not COOKIE_SECURITY_HINT.search(window):
|
||||
findings.append(
|
||||
Signal(
|
||||
"INSECURE_COOKIE",
|
||||
"Cookie set without HttpOnly, Secure or SameSite flags",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
2.0,
|
||||
_line_of(text, cookie_match.start()),
|
||||
cookie_match.group(0)[:80],
|
||||
)
|
||||
)
|
||||
mixed = MIXED_CONTENT_PATTERN.search(text)
|
||||
if mixed:
|
||||
findings.append(
|
||||
Signal(
|
||||
"MIXED_CONTENT",
|
||||
"Insecure http:// resource referenced",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
_line_of(text, mixed.start()),
|
||||
mixed.group(0)[:80],
|
||||
)
|
||||
)
|
||||
|
||||
if language in JS_LANGUAGES:
|
||||
conditional_hook = CONDITIONAL_HOOK_PATTERN.search(text)
|
||||
if conditional_hook:
|
||||
findings.append(
|
||||
Signal(
|
||||
"CONDITIONAL_HOOK",
|
||||
"React hook called conditionally, violates rules of hooks",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
2.0,
|
||||
_line_of(text, conditional_hook.start()),
|
||||
conditional_hook.group(0).strip()[:80],
|
||||
)
|
||||
)
|
||||
findings.extend(_effect_cleanup_findings(text))
|
||||
unguarded = JSON_PARSE_UNGUARDED_PATTERN.search(text)
|
||||
if unguarded and "try" not in text[max(0, unguarded.start() - 120): unguarded.start()]:
|
||||
findings.append(
|
||||
Signal(
|
||||
"UNGUARDED_JSON_PARSE",
|
||||
"JSON.parse on a response without a guard for non-JSON payloads",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
_line_of(text, unguarded.start()),
|
||||
unguarded.group(0)[:80],
|
||||
)
|
||||
)
|
||||
if SUPABASE_SERVICE_ROLE_PATTERN.search(text) and PUBLIC_SECRET_PATTERN.search(text) is None:
|
||||
env_exposed = re.search(r"(NEXT_PUBLIC|VITE|REACT_APP)[A-Z0-9_]*[\s\S]{0,60}service_role", text, re.IGNORECASE)
|
||||
if env_exposed:
|
||||
findings.append(
|
||||
Signal(
|
||||
"SUPABASE_SERVICE_ROLE_EXPOSED",
|
||||
"Supabase service_role key reachable from client code",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_QUALITY,
|
||||
5.0,
|
||||
_line_of(text, env_exposed.start()),
|
||||
env_exposed.group(0)[:100],
|
||||
)
|
||||
)
|
||||
env_accesses = ENV_ACCESS_PATTERN.findall(text)
|
||||
env_fallbacks = ENV_FALLBACK_HINT.findall(text)
|
||||
if len(env_accesses) >= ENV_DENSITY_THRESHOLD and not env_fallbacks:
|
||||
findings.append(
|
||||
Signal(
|
||||
"ENV_NO_VALIDATION",
|
||||
f"{len(env_accesses)} process.env reads with no fallback or validation",
|
||||
SEVERITY_WEAK,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
1,
|
||||
f"{len(env_accesses)} unvalidated env reads",
|
||||
)
|
||||
)
|
||||
|
||||
return findings[:MAX_FINDINGS]
|
||||
293
devplacepy/services/jobs/isslop/analysis/signals/webtells.py
Normal file
293
devplacepy/services/jobs/isslop/analysis/signals/webtells.py
Normal file
@ -0,0 +1,293 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import (
|
||||
AXIS_ORIGIN,
|
||||
AXIS_QUALITY,
|
||||
SEVERITY_MEDIUM,
|
||||
SEVERITY_STRONG,
|
||||
SEVERITY_WEAK,
|
||||
FileContext,
|
||||
Signal,
|
||||
)
|
||||
|
||||
WEB_LANGUAGES: frozenset[str] = frozenset({"html", "css", "javascript", "typescript", "markdown"})
|
||||
|
||||
BUILDER_FINGERPRINTS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(re.compile(r"cdn\.gpteng\.co|gptengineer\.js", re.IGNORECASE), "GPT Engineer runtime script"),
|
||||
(re.compile(r"lovable-uploads|data-lov-id|lovable\.app", re.IGNORECASE), "Lovable builder artifact"),
|
||||
(re.compile(r"bolt\.new", re.IGNORECASE), "Bolt.new builder reference"),
|
||||
(re.compile(r"(made|built|created|generated)\s+with\s+(lovable|bolt|v0|base44|windsurf|cursor|replit agent)", re.IGNORECASE), "AI builder attribution"),
|
||||
(re.compile(r"<meta[^>]+generator[^>]+(lovable|bolt|v0|base44|framer|durable|10web|hostinger ai)", re.IGNORECASE), "AI site generator meta tag"),
|
||||
(re.compile(r"v0\.dev|vercel\.ai/v0", re.IGNORECASE), "Vercel v0 builder reference"),
|
||||
)
|
||||
|
||||
SIGNATURE_GRADIENT_PATTERN = re.compile(r"#667eea.{0,80}#764ba2|#764ba2.{0,80}#667eea", re.IGNORECASE | re.DOTALL)
|
||||
GENERIC_PURPLE_GRADIENT_PATTERN = re.compile(
|
||||
r"linear-gradient\([^)]*(#6366f1|#8b5cf6|#a855f7|#7c3aed|#4f46e5|#818cf8)[^)]*\)", re.IGNORECASE
|
||||
)
|
||||
|
||||
SECTION_COMMENT_PATTERN = re.compile(
|
||||
r"<!--\s*([\w][\w /&'-]{0,40}\s+section|hero|navigation|navbar|footer|header)\s*-->",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
EDIT_NARRATION_PATTERN = re.compile(
|
||||
r"<!--\s*(updated|added|new|fixed|changed|modified|improved|enhanced|revised)\b[^>]{0,60}-->",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
VIBE_STACK_INDICATORS: tuple[tuple[re.Pattern[str], int, str], ...] = (
|
||||
(re.compile(r"lucide", re.IGNORECASE), 5, "lucide icons"),
|
||||
(re.compile(r"data-radix|radix-ui|\bradix\b", re.IGNORECASE), 5, "radix primitives"),
|
||||
(re.compile(r"shadcn", re.IGNORECASE), 1, "shadcn/ui"),
|
||||
(re.compile(r"__next|/_next/", re.IGNORECASE), 2, "next.js runtime"),
|
||||
(re.compile(r"\bInter\b"), 3, "inter font"),
|
||||
(re.compile(r"framer-motion|data-framer", re.IGNORECASE), 2, "framer motion"),
|
||||
(re.compile(r"class=\"[^\"]*flex[^\"]*items-center[^\"]*justify-center[^\"]*\""), 6, "tailwind utility soup"),
|
||||
(re.compile(r"animate-(fade|pulse|bounce|spin|in)", re.IGNORECASE), 8, "animation utilities"),
|
||||
)
|
||||
INLINE_MONOLITH_STYLE_PATTERN = re.compile(r"<style[^>]*>([\s\S]*?)</style>", re.IGNORECASE)
|
||||
INLINE_MONOLITH_SCRIPT_PATTERN = re.compile(r"<script(?![^>]*src)[^>]*>([\s\S]*?)</script>", re.IGNORECASE)
|
||||
MONOLITH_STYLE_MIN_LINES: int = 80
|
||||
MONOLITH_SCRIPT_MIN_LINES: int = 40
|
||||
LANDING_SECTION_PATTERN = re.compile(
|
||||
r"(?:class|id)\s*=\s*[\"'][^\"']*\b(hero|features|testimonials|pricing|faq|cta)\b", re.IGNORECASE
|
||||
)
|
||||
|
||||
PLACEHOLDER_CONTACT_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(re.compile(r"\b(john|jane)(\.doe)?@example\.com\b", re.IGNORECASE), "Placeholder person email"),
|
||||
(re.compile(r"\b(info|contact|hello|support|your)@(example|yourcompany|yourdomain|company|yoursite)\.(com|org)\b", re.IGNORECASE), "Placeholder contact email"),
|
||||
(re.compile(r"\(555\)\s?\d{3}[- ]\d{4}|\+1\s?\(?555\)?[- ]\d{3,4}[- ]\d{4}|\b555-01\d{2}\b"), "Placeholder 555 phone number"),
|
||||
(re.compile(r"\b123 Main (Street|St)\b", re.IGNORECASE), "Placeholder street address"),
|
||||
(re.compile(r"\bYour Company( Name)?\b"), "Placeholder company name"),
|
||||
)
|
||||
|
||||
LOREM_PATTERN = re.compile(r"lorem ipsum dolor", re.IGNORECASE)
|
||||
DEAD_LINK_PATTERN = re.compile(r"href\s*=\s*[\"']#[\"']")
|
||||
TAILWIND_CDN_PATTERN = re.compile(r"cdn\.tailwindcss\.com", re.IGNORECASE)
|
||||
FONT_STACK_PATTERN = re.compile(r"fonts\.googleapis\.com[^\"']*(Inter|Poppins)", re.IGNORECASE)
|
||||
ICON_CDN_PATTERN = re.compile(r"(font-?awesome|cdnjs[^\"']*all\.min\.css|lucide)", re.IGNORECASE)
|
||||
|
||||
SMOOTH_SCROLL_PATTERN = re.compile(r"scroll-behavior\s*:\s*smooth|scrollIntoView\s*\(\s*\{\s*behavior\s*:\s*[\"']smooth")
|
||||
OBSERVER_REVEAL_PATTERN = re.compile(
|
||||
r"IntersectionObserver[\s\S]{0,400}classList\.(add|toggle)\s*\(\s*[\"'](visible|show|fade-in|animate|active|in-view)[\"']"
|
||||
)
|
||||
ANCHOR_SCRIPT_PATTERN = re.compile(r"querySelectorAll\s*\(\s*[\"']a\[href\^=[\"'\\]+#")
|
||||
|
||||
ROOT_PALETTE_PATTERN = re.compile(r"--primary(-color)?\s*:[\s\S]{0,300}--secondary(-color)?\s*:", re.IGNORECASE)
|
||||
UNIVERSAL_RESET_PATTERN = re.compile(
|
||||
r"\*\s*\{[^}]*margin\s*:\s*0[^}]*padding\s*:\s*0[^}]*box-sizing\s*:\s*border-box[^}]*\}", re.DOTALL
|
||||
)
|
||||
|
||||
HEADING_EMOJI_PATTERN = re.compile(r"<(h[1-3]|button)[^>]*>[^<]*[\U0001F300-\U0001FAFF✨🚀⭐✅]")
|
||||
STARTUP_COPY_PATTERN = re.compile(
|
||||
r"empower your (team|business)|built for (modern|the) (teams|businesses|ai era|ai age|modern web)"
|
||||
r"|unlock (the power|your potential|productivity)|transform your (business|workflow|ideas)"
|
||||
r"|elevate your|seamless integration|all rights reserved\.\s*(built|made) with",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
SECTION_COMMENT_THRESHOLD: int = 3
|
||||
LANDING_SECTION_THRESHOLD: int = 4
|
||||
DEAD_LINK_THRESHOLD: int = 5
|
||||
|
||||
|
||||
def _line_of(text: str, position: int) -> int:
|
||||
return text.count("\n", 0, position) + 1
|
||||
|
||||
|
||||
def detect_web_tells(context: FileContext) -> list[Signal]:
|
||||
if context.language not in WEB_LANGUAGES:
|
||||
return []
|
||||
findings: list[Signal] = []
|
||||
text = context.text
|
||||
|
||||
for pattern, title in BUILDER_FINGERPRINTS:
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
findings.append(
|
||||
Signal("AI_BUILDER_FINGERPRINT", title, SEVERITY_STRONG, AXIS_ORIGIN, 4.0, _line_of(text, match.start()), match.group(0)[:120])
|
||||
)
|
||||
|
||||
signature = SIGNATURE_GRADIENT_PATTERN.search(text)
|
||||
if signature:
|
||||
findings.append(
|
||||
Signal(
|
||||
"SIGNATURE_GRADIENT",
|
||||
"Canonical LLM purple gradient #667eea to #764ba2",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_ORIGIN,
|
||||
3.0,
|
||||
_line_of(text, signature.start()),
|
||||
signature.group(0)[:120],
|
||||
)
|
||||
)
|
||||
else:
|
||||
generic = GENERIC_PURPLE_GRADIENT_PATTERN.search(text)
|
||||
if generic:
|
||||
findings.append(
|
||||
Signal(
|
||||
"PURPLE_GRADIENT",
|
||||
"Indigo or violet gradient from default LLM palette",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
_line_of(text, generic.start()),
|
||||
generic.group(0)[:120],
|
||||
)
|
||||
)
|
||||
|
||||
stack_hits: list[str] = []
|
||||
for pattern, minimum, label in VIBE_STACK_INDICATORS:
|
||||
if len(pattern.findall(text)) >= minimum:
|
||||
stack_hits.append(label)
|
||||
if len(stack_hits) >= 3:
|
||||
findings.append(
|
||||
Signal(
|
||||
"VIBE_STACK",
|
||||
f"Default AI frontend stack fingerprint ({len(stack_hits)} indicators)",
|
||||
SEVERITY_STRONG if len(stack_hits) >= 5 else SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
3.0,
|
||||
1,
|
||||
", ".join(stack_hits),
|
||||
)
|
||||
)
|
||||
|
||||
if context.language == "html":
|
||||
section_comments = SECTION_COMMENT_PATTERN.findall(text)
|
||||
if len(section_comments) >= SECTION_COMMENT_THRESHOLD:
|
||||
findings.append(
|
||||
Signal(
|
||||
"SECTION_BANNER_COMMENTS",
|
||||
f"{len(section_comments)} template section banner comments",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
1,
|
||||
", ".join(section_comments[:6]),
|
||||
)
|
||||
)
|
||||
edit_narrations = EDIT_NARRATION_PATTERN.findall(text)
|
||||
if edit_narrations:
|
||||
match = EDIT_NARRATION_PATTERN.search(text)
|
||||
findings.append(
|
||||
Signal(
|
||||
"EDIT_NARRATION_COMMENT",
|
||||
f"{len(edit_narrations)} chat-edit narration comments (Updated/Added/New ...)",
|
||||
SEVERITY_STRONG,
|
||||
AXIS_ORIGIN,
|
||||
3.0,
|
||||
_line_of(text, match.start()) if match else 1,
|
||||
match.group(0)[:100] if match else "",
|
||||
)
|
||||
)
|
||||
style_blocks = INLINE_MONOLITH_STYLE_PATTERN.findall(text)
|
||||
script_blocks = INLINE_MONOLITH_SCRIPT_PATTERN.findall(text)
|
||||
style_lines = sum(block.count("\n") for block in style_blocks)
|
||||
script_lines = sum(block.count("\n") for block in script_blocks)
|
||||
if style_lines >= MONOLITH_STYLE_MIN_LINES and script_lines >= MONOLITH_SCRIPT_MIN_LINES:
|
||||
findings.append(
|
||||
Signal(
|
||||
"SINGLE_FILE_MONOLITH",
|
||||
f"Single-file page with {style_lines} CSS and {script_lines} JS lines inlined, the chat-output shape",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
1,
|
||||
f"inline style {style_lines} lines, inline script {script_lines} lines",
|
||||
)
|
||||
)
|
||||
landing_sections = {match.group(1).lower() for match in LANDING_SECTION_PATTERN.finditer(text)}
|
||||
if len(landing_sections) >= LANDING_SECTION_THRESHOLD:
|
||||
findings.append(
|
||||
Signal(
|
||||
"LANDING_TEMPLATE",
|
||||
"Canonical hero-features-testimonials-pricing landing structure",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
2.0,
|
||||
1,
|
||||
", ".join(sorted(landing_sections)),
|
||||
)
|
||||
)
|
||||
dead_links = DEAD_LINK_PATTERN.findall(text)
|
||||
if len(dead_links) >= DEAD_LINK_THRESHOLD:
|
||||
findings.append(
|
||||
Signal(
|
||||
"DEAD_ANCHOR_LINKS",
|
||||
f'{len(dead_links)} links pointing to href="#"',
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_QUALITY,
|
||||
1.0,
|
||||
1,
|
||||
f"{len(dead_links)} placeholder links",
|
||||
)
|
||||
)
|
||||
if TAILWIND_CDN_PATTERN.search(text):
|
||||
findings.append(
|
||||
Signal("TAILWIND_CDN", "Tailwind loaded from CDN, the LLM prototype default", SEVERITY_WEAK, AXIS_ORIGIN, 1.0, 1, "cdn.tailwindcss.com")
|
||||
)
|
||||
fonts = FONT_STACK_PATTERN.search(text)
|
||||
if fonts and ICON_CDN_PATTERN.search(text):
|
||||
findings.append(
|
||||
Signal("DEFAULT_FONT_STACK", f"Default LLM font and icon stack ({fonts.group(1)} plus icon CDN)", SEVERITY_WEAK, AXIS_ORIGIN, 1.0, _line_of(text, fonts.start()), fonts.group(0)[:100])
|
||||
)
|
||||
emoji_heading = HEADING_EMOJI_PATTERN.search(text)
|
||||
if emoji_heading:
|
||||
findings.append(
|
||||
Signal("EMOJI_HEADINGS", "Emoji inside headings or buttons", SEVERITY_MEDIUM, AXIS_ORIGIN, 1.0, _line_of(text, emoji_heading.start()), emoji_heading.group(0)[:100])
|
||||
)
|
||||
|
||||
for pattern, title in PLACEHOLDER_CONTACT_PATTERNS:
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
findings.append(
|
||||
Signal("PLACEHOLDER_CONTENT", title, SEVERITY_MEDIUM, AXIS_QUALITY, 2.0, _line_of(text, match.start()), match.group(0)[:100])
|
||||
)
|
||||
|
||||
lorem = LOREM_PATTERN.search(text)
|
||||
if lorem:
|
||||
findings.append(
|
||||
Signal("PLACEHOLDER_CONTENT", "Lorem ipsum filler text left in source", SEVERITY_MEDIUM, AXIS_QUALITY, 2.0, _line_of(text, lorem.start()), lorem.group(0))
|
||||
)
|
||||
|
||||
copy_match = STARTUP_COPY_PATTERN.search(text)
|
||||
if copy_match:
|
||||
findings.append(
|
||||
Signal("TEMPLATE_COPY", "Stock AI landing page copy phrase", SEVERITY_MEDIUM, AXIS_ORIGIN, 1.0, _line_of(text, copy_match.start()), copy_match.group(0)[:100])
|
||||
)
|
||||
|
||||
if context.language in ("javascript", "typescript", "html"):
|
||||
observer = OBSERVER_REVEAL_PATTERN.search(text)
|
||||
if observer:
|
||||
findings.append(
|
||||
Signal(
|
||||
"SCROLL_REVEAL_BOILERPLATE",
|
||||
"IntersectionObserver fade-in reveal boilerplate",
|
||||
SEVERITY_MEDIUM,
|
||||
AXIS_ORIGIN,
|
||||
1.0,
|
||||
_line_of(text, observer.start()),
|
||||
observer.group(0)[:100],
|
||||
)
|
||||
)
|
||||
if SMOOTH_SCROLL_PATTERN.search(text) and ANCHOR_SCRIPT_PATTERN.search(text):
|
||||
findings.append(
|
||||
Signal("SMOOTH_SCROLL_BOILERPLATE", "Smooth-scroll anchor polyfill boilerplate", SEVERITY_MEDIUM, AXIS_ORIGIN, 1.0, 1, "smooth scroll + anchor querySelectorAll")
|
||||
)
|
||||
|
||||
if context.language in ("css", "html"):
|
||||
palette = ROOT_PALETTE_PATTERN.search(text)
|
||||
if palette:
|
||||
findings.append(
|
||||
Signal("DEFAULT_CSS_PALETTE", "Generic --primary/--secondary variable palette", SEVERITY_MEDIUM, AXIS_ORIGIN, 1.0, _line_of(text, palette.start()), palette.group(0)[:80])
|
||||
)
|
||||
reset = UNIVERSAL_RESET_PATTERN.search(text)
|
||||
if reset:
|
||||
findings.append(
|
||||
Signal("UNIVERSAL_RESET", "Universal margin/padding/box-sizing reset block", SEVERITY_WEAK, AXIS_ORIGIN, 1.0, _line_of(text, reset.start()), "* { margin:0; padding:0; box-sizing:border-box }")
|
||||
)
|
||||
|
||||
return findings[:14]
|
||||
170
devplacepy/services/jobs/isslop/analysis/templates.py
Normal file
170
devplacepy/services/jobs/isslop/analysis/templates.py
Normal file
@ -0,0 +1,170 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
README_SCAN_BYTES: int = 6144
|
||||
SATURATION: float = 16.0
|
||||
README_CAP: float = 18.0
|
||||
CONSTELLATION_FREEBIES: int = 3
|
||||
CONSTELLATION_STEP: float = 1.2
|
||||
CONSTELLATION_CAP: float = 12.0
|
||||
|
||||
KNOWN_TEMPLATE_SLUGS: tuple[tuple[str, float], ...] = (
|
||||
("next-js-boilerplate", 9.0),
|
||||
("nextjs-boilerplate", 9.0),
|
||||
("next-js-blog-boilerplate", 9.0),
|
||||
("vercel-ai-chatbot", 9.0),
|
||||
("ai-chatbot", 9.0),
|
||||
("create-t3-app", 9.0),
|
||||
("fullstack-nextjs-app-template", 9.0),
|
||||
("my-v0-project", 9.0),
|
||||
("nextjs-starter", 8.0),
|
||||
("saas-starter", 8.0),
|
||||
("astro-boilerplate", 8.0),
|
||||
("vite-project", 7.0),
|
||||
("my-t3-app", 9.0),
|
||||
("my-app", 5.0),
|
||||
("chatbot", 5.0),
|
||||
)
|
||||
|
||||
KNOWN_TEMPLATE_AUTHORS: tuple[str, ...] = (
|
||||
"ixartz",
|
||||
"uiux lab",
|
||||
"shoubhit dash",
|
||||
"vercel",
|
||||
"shadcn",
|
||||
"steven tey",
|
||||
)
|
||||
|
||||
GENERIC_IDENTITY_PATTERN = re.compile(r"\b(boilerplate|starter|template|scaffold)\b", re.IGNORECASE)
|
||||
|
||||
README_MARKERS: tuple[tuple[str, float, str], ...] = (
|
||||
(r"bootstrapped with .{0,40}create", 10.0, "bootstrapped-with"),
|
||||
(r"create[- ]t3[- ]app|\bt3 stack\b", 9.0, "t3-stack"),
|
||||
(r"(free,? )?open[- ]source (template|starter)|\b(app|chatbot|website) template\b|template built with", 9.0, "template-marketing"),
|
||||
(r"\bboilerplate\b", 8.0, "boilerplate-wording"),
|
||||
(r"\bstarter\b", 6.0, "starter-wording"),
|
||||
(r"getting started.{0,160}first,? run the development server", 7.0, "default-readme"),
|
||||
(r"deploy your own|one[- ]click deploy|deploy with vercel", 5.0, "deploy-your-own"),
|
||||
(r"https?://demo\.|/demo/", 4.0, "hosted-demo"),
|
||||
(r"\bsponsors?\b", 3.0, "sponsor-section"),
|
||||
)
|
||||
|
||||
SCAFFOLD_ARTIFACTS: tuple[str, ...] = (
|
||||
".storybook",
|
||||
".husky",
|
||||
".devcontainer",
|
||||
".github/FUNDING.yml",
|
||||
".github/dependabot.yml",
|
||||
".github/workflows",
|
||||
"commitlint.config.js",
|
||||
"commitlint.config.ts",
|
||||
"lint-staged.config.js",
|
||||
".lintstagedrc",
|
||||
".lintstagedrc.js",
|
||||
"renovate.json",
|
||||
"playwright.config.js",
|
||||
"playwright.config.ts",
|
||||
"vitest.config.js",
|
||||
"vitest.config.ts",
|
||||
"drizzle.config.ts",
|
||||
"components.json",
|
||||
"crowdin.yml",
|
||||
".releaserc",
|
||||
".releaserc.json",
|
||||
"docker-compose.yml",
|
||||
"Dockerfile",
|
||||
"sentry.client.config.ts",
|
||||
"sentry.server.config.ts",
|
||||
"CHANGELOG.md",
|
||||
"CODE_OF_CONDUCT.md",
|
||||
"oxlint.config.ts",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TemplateEvidence:
|
||||
score: float = 0.0
|
||||
markers: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _manifest_markers(root: Path) -> tuple[float, list[str]]:
|
||||
manifest = root / "package.json"
|
||||
if not manifest.exists():
|
||||
return 0.0, []
|
||||
try:
|
||||
body = json.loads(manifest.read_text(encoding="utf-8", errors="replace"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return 0.0, []
|
||||
if not isinstance(body, dict):
|
||||
return 0.0, []
|
||||
weight = 0.0
|
||||
markers: list[str] = []
|
||||
name = str(body.get("name") or "").lower()
|
||||
matched_slug = next(
|
||||
((slug, slug_weight) for slug, slug_weight in KNOWN_TEMPLATE_SLUGS if slug in name), None
|
||||
)
|
||||
if matched_slug:
|
||||
weight += matched_slug[1]
|
||||
markers.append(f"package name matches known template '{matched_slug[0]}'")
|
||||
identity = f"{name} {str(body.get('description') or '')}"
|
||||
if not matched_slug and GENERIC_IDENTITY_PATTERN.search(identity):
|
||||
weight += 7.0
|
||||
markers.append("package identity describes itself as a boilerplate/starter/template")
|
||||
author = str(body.get("author") or "").lower()
|
||||
if any(known in author for known in KNOWN_TEMPLATE_AUTHORS):
|
||||
weight += 6.0
|
||||
markers.append("package author is a known template publisher")
|
||||
if "ct3aMetadata" in body:
|
||||
weight += 10.0
|
||||
markers.append("create-t3-app scaffold metadata present")
|
||||
return weight, markers
|
||||
|
||||
|
||||
def _readme_markers(root: Path) -> tuple[float, list[str]]:
|
||||
readme = next((root / name for name in ("README.md", "readme.md", "README") if (root / name).exists()), None)
|
||||
if readme is None:
|
||||
return 0.0, []
|
||||
try:
|
||||
text = readme.read_text(encoding="utf-8", errors="replace")[:README_SCAN_BYTES]
|
||||
except OSError:
|
||||
return 0.0, []
|
||||
weight = 0.0
|
||||
markers: list[str] = []
|
||||
for pattern, marker_weight, label in README_MARKERS:
|
||||
if re.search(pattern, text, re.IGNORECASE | re.DOTALL):
|
||||
weight += marker_weight
|
||||
markers.append(f"README carries template marker: {label}")
|
||||
return min(weight, README_CAP), markers
|
||||
|
||||
|
||||
def _constellation_markers(root: Path) -> tuple[float, list[str]]:
|
||||
present = [artifact for artifact in SCAFFOLD_ARTIFACTS if (root / artifact).exists()]
|
||||
extra = max(0, len(present) - CONSTELLATION_FREEBIES)
|
||||
if extra <= 0:
|
||||
return 0.0, []
|
||||
weight = min(extra * CONSTELLATION_STEP, CONSTELLATION_CAP)
|
||||
return weight, [f"kitchen-sink scaffold constellation: {len(present)} standard scaffold artifacts"]
|
||||
|
||||
|
||||
def detect_template(root: Path) -> TemplateEvidence:
|
||||
weight = 0.0
|
||||
markers: list[str] = []
|
||||
for probe in (_manifest_markers, _readme_markers, _constellation_markers):
|
||||
try:
|
||||
probe_weight, probe_markers = probe(root)
|
||||
except OSError as error:
|
||||
logger.warning("Template probe failed in %s: %s", root, error)
|
||||
continue
|
||||
weight += probe_weight
|
||||
markers.extend(probe_markers)
|
||||
score = round(100.0 * (1.0 - math.exp(-weight / SATURATION)), 1) if weight > 0 else 0.0
|
||||
return TemplateEvidence(score=score, markers=markers)
|
||||
82
devplacepy/services/jobs/isslop/badge.py
Normal file
82
devplacepy/services/jobs/isslop/badge.py
Normal file
@ -0,0 +1,82 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
from html import escape
|
||||
|
||||
GRADE_COLORS: dict[str, str] = {
|
||||
"A": "#34a853",
|
||||
"B": "#7cb342",
|
||||
"C": "#fbbc04",
|
||||
"D": "#fb8c00",
|
||||
"F": "#ea4335",
|
||||
}
|
||||
LABEL_COLOR: str = "#555555"
|
||||
PENDING_COLOR: str = "#9e9e9e"
|
||||
CHAR_WIDTH: float = 6.3
|
||||
HORIZONTAL_PADDING: float = 10.0
|
||||
BADGE_HEIGHT: int = 20
|
||||
LABEL_TEXT: str = "authenticity · human"
|
||||
|
||||
|
||||
def _segment_width(text: str) -> float:
|
||||
return len(text) * CHAR_WIDTH + HORIZONTAL_PADDING
|
||||
|
||||
|
||||
def render_badge(human_percent: float | str | None, grade: str | None, report_url: str) -> str:
|
||||
percent: float | None = None
|
||||
if human_percent is not None:
|
||||
try:
|
||||
percent = float(human_percent)
|
||||
except (TypeError, ValueError):
|
||||
percent = None
|
||||
if percent is None or not grade:
|
||||
value_text = "analyzing"
|
||||
value_color = PENDING_COLOR
|
||||
else:
|
||||
value_text = f"{percent:.0f}% · {grade}"
|
||||
value_color = GRADE_COLORS.get(grade, PENDING_COLOR)
|
||||
label_width = _segment_width(LABEL_TEXT)
|
||||
value_width = _segment_width(value_text)
|
||||
total_width = label_width + value_width
|
||||
label_center = label_width / 2.0
|
||||
value_center = label_width + value_width / 2.0
|
||||
safe_url = escape(report_url, quote=True)
|
||||
safe_value = escape(value_text)
|
||||
safe_label = escape(LABEL_TEXT)
|
||||
return (
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" '
|
||||
f'width="{total_width:.0f}" height="{BADGE_HEIGHT}" role="img" aria-label="{safe_label}: {safe_value}">'
|
||||
f'<title>{safe_label}: {safe_value}</title>'
|
||||
f'<a xlink:href="{safe_url}" target="_blank">'
|
||||
f'<linearGradient id="s" x2="0" y2="100%">'
|
||||
f'<stop offset="0" stop-color="#bbbbbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/>'
|
||||
f'</linearGradient>'
|
||||
f'<clipPath id="r"><rect width="{total_width:.0f}" height="{BADGE_HEIGHT}" rx="3" fill="#ffffff"/></clipPath>'
|
||||
f'<g clip-path="url(#r)">'
|
||||
f'<rect width="{label_width:.0f}" height="{BADGE_HEIGHT}" fill="{LABEL_COLOR}"/>'
|
||||
f'<rect x="{label_width:.0f}" width="{value_width:.0f}" height="{BADGE_HEIGHT}" fill="{value_color}"/>'
|
||||
f'<rect width="{total_width:.0f}" height="{BADGE_HEIGHT}" fill="url(#s)"/>'
|
||||
f'</g>'
|
||||
f'<g fill="#ffffff" text-anchor="middle" '
|
||||
f'font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">'
|
||||
f'<text aria-hidden="true" x="{label_center * 10:.0f}" y="150" fill="#010101" fill-opacity=".3" '
|
||||
f'transform="scale(.1)" textLength="{(label_width - HORIZONTAL_PADDING) * 10:.0f}">{safe_label}</text>'
|
||||
f'<text x="{label_center * 10:.0f}" y="140" transform="scale(.1)" '
|
||||
f'textLength="{(label_width - HORIZONTAL_PADDING) * 10:.0f}">{safe_label}</text>'
|
||||
f'<text aria-hidden="true" x="{value_center * 10:.0f}" y="150" fill="#010101" fill-opacity=".3" '
|
||||
f'transform="scale(.1)" textLength="{(value_width - HORIZONTAL_PADDING) * 10:.0f}">{safe_value}</text>'
|
||||
f'<text x="{value_center * 10:.0f}" y="140" transform="scale(.1)" '
|
||||
f'textLength="{(value_width - HORIZONTAL_PADDING) * 10:.0f}">{safe_value}</text>'
|
||||
f'</g></a></svg>'
|
||||
)
|
||||
|
||||
|
||||
def badge_markdown(badge_url: str, report_url: str) -> str:
|
||||
return f"[]({report_url})"
|
||||
|
||||
|
||||
def badge_html(badge_url: str, report_url: str) -> str:
|
||||
return (
|
||||
f'<a href="{escape(report_url, quote=True)}">'
|
||||
f'<img src="{escape(badge_url, quote=True)}" alt="authenticity human score"/></a>'
|
||||
)
|
||||
75
devplacepy/services/jobs/isslop/config.py
Normal file
75
devplacepy/services/jobs/isslop/config.py
Normal file
@ -0,0 +1,75 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
GIT_SIZE_LIMIT_BYTES: int = 3 * 1024 * 1024 * 1024
|
||||
GIT_CLONE_TIMEOUT_SECONDS: int = 900
|
||||
GIT_PROBE_TIMEOUT_SECONDS: int = 25
|
||||
GIT_SIZE_POLL_SECONDS: float = 2.0
|
||||
|
||||
WEBSITE_MAX_PAGES: int = 40
|
||||
WEBSITE_MAX_FILES: int = 150
|
||||
WEBSITE_MAX_TOTAL_BYTES: int = 80 * 1024 * 1024
|
||||
WEBSITE_MAX_FILE_BYTES: int = 5 * 1024 * 1024
|
||||
WEBSITE_MAX_DEPTH: int = 2
|
||||
WEBSITE_REQUEST_TIMEOUT_SECONDS: float = 30.0
|
||||
|
||||
ANALYSIS_FILE_CAP_BYTES: int = 512 * 1024
|
||||
ANALYSIS_MAX_FILES: int = 2000
|
||||
AI_SAMPLE_LIMIT: int = 12
|
||||
AI_EXCERPT_CHARS: int = 6000
|
||||
|
||||
LLM_MODEL: str = "molodetz"
|
||||
LLM_TIMEOUT_SECONDS: float = 180.0
|
||||
LLM_MAX_RETRIES: int = 3
|
||||
LLM_RETRY_BACKOFF_SECONDS: float = 2.0
|
||||
LLM_TEMPERATURE: float = 0.0
|
||||
|
||||
WORKER_TIMEOUT_SECONDS: int = 3600
|
||||
SOURCE_URL_MAX_LENGTH: int = 2048
|
||||
|
||||
IMAGE_MAX_COUNT: int = 20
|
||||
IMAGE_CONCURRENCY: int = 5
|
||||
IMAGE_MIN_BYTES: int = 6 * 1024
|
||||
IMAGE_MAX_BYTES: int = 12 * 1024 * 1024
|
||||
IMAGE_MIN_DIMENSION: int = 128
|
||||
IMAGE_EXTENSIONS: tuple[str, ...] = (".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".gif")
|
||||
VISION_TIMEOUT_SECONDS: float = 120.0
|
||||
|
||||
|
||||
THUMBNAIL_MAX_DIMENSION: int = 480
|
||||
THUMBNAIL_QUALITY: int = 80
|
||||
|
||||
DOM_ANALYSIS_MAX_PAGES: int = 1
|
||||
DOM_CAPTURE_TIMEOUT_MS: int = 8000
|
||||
CONSOLE_SAMPLE_CAP: int = 20
|
||||
HEADER_VALUE_CAP: int = 500
|
||||
DOM_AI_WEIGHT: float = 0.12
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerSettings:
|
||||
allow_private_hosts: bool
|
||||
llm_endpoint: str
|
||||
llm_model: str
|
||||
llm_api_key: str
|
||||
ai_review_enabled: bool
|
||||
image_review_enabled: bool
|
||||
image_max_count: int
|
||||
media_dir: str
|
||||
template_detection: bool
|
||||
|
||||
|
||||
def settings_from_payload(payload: dict) -> WorkerSettings:
|
||||
return WorkerSettings(
|
||||
allow_private_hosts=bool(payload.get("allow_private", False)),
|
||||
llm_endpoint=str(payload.get("llm_endpoint", "")),
|
||||
llm_model=str(payload.get("llm_model", LLM_MODEL)),
|
||||
llm_api_key=str(payload.get("api_key", "")),
|
||||
ai_review_enabled=bool(payload.get("ai_review", True)),
|
||||
image_review_enabled=bool(payload.get("image_review", True)),
|
||||
image_max_count=int(payload.get("image_max", IMAGE_MAX_COUNT)),
|
||||
media_dir=str(payload.get("media_dir", "")),
|
||||
template_detection=bool(payload.get("template_detection", True)),
|
||||
)
|
||||
59
devplacepy/services/jobs/isslop/events.py
Normal file
59
devplacepy/services/jobs/isslop/events.py
Normal file
@ -0,0 +1,59 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KIND_STAGE: str = "stage"
|
||||
KIND_LOG: str = "log"
|
||||
KIND_FILE: str = "file"
|
||||
KIND_SIGNAL: str = "signal"
|
||||
KIND_AI: str = "ai"
|
||||
KIND_IMAGE: str = "image"
|
||||
KIND_DOM: str = "dom"
|
||||
KIND_PROGRESS: str = "progress"
|
||||
KIND_SCORE: str = "score"
|
||||
KIND_REPORT: str = "report"
|
||||
KIND_ERROR: str = "error"
|
||||
KIND_DONE: str = "done"
|
||||
|
||||
STAGE_RESOLVE: str = "resolve"
|
||||
STAGE_ACQUIRE: str = "acquire"
|
||||
STAGE_INVENTORY: str = "inventory"
|
||||
STAGE_STATIC: str = "static-analysis"
|
||||
STAGE_AI: str = "ai-review"
|
||||
STAGE_IMAGES: str = "image-review"
|
||||
STAGE_DOM: str = "dom-analysis"
|
||||
STAGE_SCORE: str = "scoring"
|
||||
STAGE_REPORT: str = "report"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerEvent:
|
||||
kind: str
|
||||
message: str
|
||||
data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps({"kind": self.kind, "message": self.message, "data": self.data}, ensure_ascii=False)
|
||||
|
||||
@staticmethod
|
||||
def parse(line: str) -> Optional["WorkerEvent"]:
|
||||
line = line.strip()
|
||||
if not line or not line.startswith("{"):
|
||||
return None
|
||||
try:
|
||||
body = json.loads(line)
|
||||
except json.JSONDecodeError as error:
|
||||
logger.debug("Unparseable worker line: %s (%s)", line[:120], error)
|
||||
return None
|
||||
kind = body.get("kind")
|
||||
message = body.get("message")
|
||||
data = body.get("data")
|
||||
if not isinstance(kind, str) or not isinstance(message, str):
|
||||
return None
|
||||
return WorkerEvent(kind=kind, message=message, data=data if isinstance(data, dict) else {})
|
||||
130
devplacepy/services/jobs/isslop/persistence.py
Normal file
130
devplacepy/services/jobs/isslop/persistence.py
Normal file
@ -0,0 +1,130 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.services.jobs.isslop import store
|
||||
from devplacepy.services.jobs.isslop.events import (
|
||||
KIND_DOM,
|
||||
KIND_DONE,
|
||||
KIND_ERROR,
|
||||
KIND_FILE,
|
||||
KIND_IMAGE,
|
||||
KIND_REPORT,
|
||||
WorkerEvent,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCORE_FIELDS: tuple[str, ...] = (
|
||||
"grade",
|
||||
"slop_score",
|
||||
"origin_score",
|
||||
"human_percent",
|
||||
"ai_percent",
|
||||
"category",
|
||||
"confidence",
|
||||
"content_hash",
|
||||
"source_kind",
|
||||
"files_total",
|
||||
"files_analyzed",
|
||||
"detected_builder",
|
||||
)
|
||||
|
||||
ERROR_MESSAGE_CAP = 2000
|
||||
TELLS_CAP = 4000
|
||||
DESCRIPTION_CAP = 2000
|
||||
|
||||
|
||||
class EventPersister:
|
||||
def __init__(self, analysis_uid: str) -> None:
|
||||
self._analysis_uid = analysis_uid
|
||||
self._seq = 0
|
||||
|
||||
@property
|
||||
def seq(self) -> int:
|
||||
return self._seq
|
||||
|
||||
@property
|
||||
def analysis_uid(self) -> str:
|
||||
return self._analysis_uid
|
||||
|
||||
def apply(self, event: WorkerEvent) -> dict[str, Any]:
|
||||
self._seq += 1
|
||||
payload_for_storage = dict(event.data)
|
||||
if event.kind == KIND_REPORT:
|
||||
markdown = str(payload_for_storage.pop("markdown", ""))
|
||||
model_used = str(payload_for_storage.get("model_used", "unknown"))
|
||||
store.save_report(self._analysis_uid, markdown, model_used)
|
||||
store.insert_event(
|
||||
self._analysis_uid,
|
||||
self._seq,
|
||||
event.kind,
|
||||
event.message,
|
||||
json.dumps(payload_for_storage, ensure_ascii=False),
|
||||
)
|
||||
if event.kind == KIND_FILE:
|
||||
store.insert_file_result(
|
||||
self._analysis_uid,
|
||||
{
|
||||
"path": str(event.data.get("path", "")),
|
||||
"language": str(event.data.get("language", "unknown")),
|
||||
"lines": int(event.data.get("sloc", 0)),
|
||||
"origin_score": float(event.data.get("origin_score", 0.0)),
|
||||
"quality_deficit_score": float(event.data.get("quality_deficit", 0.0)),
|
||||
"category": str(event.data.get("category", "uncertain")),
|
||||
"signals": json.dumps(event.data.get("signals", []), ensure_ascii=False)[: store.SIGNALS_CAP],
|
||||
"source": str(event.data.get("source") or ""),
|
||||
},
|
||||
)
|
||||
if event.kind == KIND_IMAGE:
|
||||
store.insert_image_result(
|
||||
self._analysis_uid,
|
||||
{
|
||||
"path": str(event.data.get("path", "")),
|
||||
"ai_probability": float(event.data.get("ai_probability", 0.0)),
|
||||
"grade": str(event.data.get("grade", "n/a")),
|
||||
"verdict": str(event.data.get("verdict", "uncertain")),
|
||||
"image_kind": str(event.data.get("image_kind", "image")),
|
||||
"tells": json.dumps(event.data.get("tells", []), ensure_ascii=False)[:TELLS_CAP],
|
||||
"description": str(event.data.get("description", ""))[:DESCRIPTION_CAP],
|
||||
"thumb": str(event.data.get("thumb") or ""),
|
||||
},
|
||||
)
|
||||
if event.kind == KIND_DOM:
|
||||
store.insert_dom_result(
|
||||
self._analysis_uid,
|
||||
{
|
||||
"url": str(event.data.get("url", "")),
|
||||
"detected_builder": str(event.data.get("detected_builder") or ""),
|
||||
"signal_count": int(event.data.get("signal_count", 0)),
|
||||
"screenshot": str(event.data.get("screenshot") or ""),
|
||||
"signals": json.dumps(event.data.get("signals", []), ensure_ascii=False)[: store.SIGNALS_CAP],
|
||||
},
|
||||
)
|
||||
if event.kind == KIND_DONE:
|
||||
updates: dict[str, Any] = {
|
||||
key: event.data.get(key) for key in SCORE_FIELDS if key in event.data
|
||||
}
|
||||
updates["quality_deficit_score"] = event.data.get("quality_deficit")
|
||||
updates["dom_slop_score"] = event.data.get("dom_slop_score")
|
||||
updates["status"] = "completed"
|
||||
updates["finished_at"] = store.utc_now()
|
||||
store.update_analysis(self._analysis_uid, **updates)
|
||||
if event.kind == KIND_ERROR:
|
||||
store.update_analysis(
|
||||
self._analysis_uid,
|
||||
status="failed",
|
||||
finished_at=store.utc_now(),
|
||||
error_message_text=event.message[:ERROR_MESSAGE_CAP],
|
||||
)
|
||||
return {
|
||||
"analysis": self._analysis_uid,
|
||||
"seq": self._seq,
|
||||
"kind": event.kind,
|
||||
"message": event.message,
|
||||
"data": payload_for_storage if event.kind != KIND_FILE else event.data,
|
||||
"created_at": store.utc_now(),
|
||||
}
|
||||
481
devplacepy/services/jobs/isslop/pipeline.py
Normal file
481
devplacepy/services/jobs/isslop/pipeline.py
Normal file
@ -0,0 +1,481 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
|
||||
from devplacepy.services.jobs.isslop.acquisition.domcapture import DomSnapshot
|
||||
from devplacepy.services.jobs.isslop.acquisition.git import CloneFailedError, RepositoryTooLargeError, clone_repository
|
||||
from devplacepy.services.jobs.isslop.acquisition.source import KIND_GIT, is_private_host, resolve_source
|
||||
from devplacepy.services.jobs.isslop.acquisition.website import crawl_website
|
||||
from devplacepy.services.jobs.isslop.acquisition.workspace import content_hash, remove_workspace, reset_workspace
|
||||
from devplacepy.services.jobs.isslop.agent.classifier import AiVerdict, classify_file, select_samples
|
||||
from devplacepy.services.jobs.isslop.agent.llm import LlmClient
|
||||
from devplacepy.services.jobs.isslop.agent.reporter import generate_report
|
||||
from devplacepy.services.jobs.isslop.agent.vision import (
|
||||
ImageVerdict,
|
||||
classify_image,
|
||||
collect_images,
|
||||
image_summary,
|
||||
make_thumbnail,
|
||||
persist_screenshot,
|
||||
)
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.aggregate import aggregate_dom_evidence
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.base import DomPageContext
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.builders import detected_builder as detected_builder_for
|
||||
from devplacepy.services.jobs.isslop.analysis.domsignals.registry import run_dom_checks
|
||||
from devplacepy.services.jobs.isslop.analysis.engine import build_inventory, compute_repo_baselines, load_context, score_file
|
||||
from devplacepy.services.jobs.isslop.analysis.scoring import (
|
||||
FileScore,
|
||||
adjust_for_dom_signals,
|
||||
adjust_for_images,
|
||||
adjust_for_template,
|
||||
aggregate,
|
||||
categorize,
|
||||
criticality_for,
|
||||
repo_scores_to_dict,
|
||||
)
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import SEVERITY_STRONG
|
||||
from devplacepy.services.jobs.isslop.analysis.templates import TemplateEvidence, detect_template
|
||||
from devplacepy.services.jobs.isslop.config import AI_EXCERPT_CHARS, IMAGE_CONCURRENCY, WorkerSettings
|
||||
from devplacepy.services.jobs.isslop.events import (
|
||||
KIND_AI,
|
||||
KIND_DOM,
|
||||
KIND_DONE,
|
||||
KIND_ERROR,
|
||||
KIND_FILE,
|
||||
KIND_LOG,
|
||||
KIND_PROGRESS,
|
||||
KIND_REPORT,
|
||||
KIND_SCORE,
|
||||
KIND_SIGNAL,
|
||||
KIND_STAGE,
|
||||
STAGE_ACQUIRE,
|
||||
STAGE_AI,
|
||||
STAGE_DOM,
|
||||
STAGE_IMAGES,
|
||||
STAGE_INVENTORY,
|
||||
STAGE_REPORT,
|
||||
STAGE_RESOLVE,
|
||||
STAGE_SCORE,
|
||||
STAGE_STATIC,
|
||||
KIND_IMAGE,
|
||||
WorkerEvent,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROGRESS_EVERY_FILES: int = 5
|
||||
SOURCE_CAP_FILES: int = 60
|
||||
SOURCE_CAP_BYTES: int = 200000
|
||||
STATIC_BLEND_WEIGHT: float = 0.6
|
||||
AI_BLEND_WEIGHT: float = 0.4
|
||||
AI_REVIEW_CONCURRENCY: int = 4
|
||||
|
||||
|
||||
def _persist_source(media_dir: Path | None, relative: str, text: str) -> str | None:
|
||||
if media_dir is None:
|
||||
return None
|
||||
name = f"s{hashlib.sha1(relative.encode('utf-8')).hexdigest()[:16]}.txt"
|
||||
try:
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
(media_dir / name).write_text(text[:SOURCE_CAP_BYTES], encoding="utf-8")
|
||||
except OSError as error:
|
||||
logger.warning("Source snippet persist failed for %s: %s", relative, error)
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
def _stage(stage: str, message: str) -> WorkerEvent:
|
||||
return WorkerEvent(kind=KIND_STAGE, message=message, data={"stage": stage})
|
||||
|
||||
|
||||
def _blend(static_value: float, ai_value: float) -> float:
|
||||
return round(STATIC_BLEND_WEIGHT * static_value + AI_BLEND_WEIGHT * ai_value, 1)
|
||||
|
||||
|
||||
def apply_ai_verdicts(scores: list[FileScore], verdicts: list[AiVerdict]) -> list[FileScore]:
|
||||
by_path = {verdict.path: verdict for verdict in verdicts}
|
||||
adjusted: list[FileScore] = []
|
||||
for score in scores:
|
||||
verdict = by_path.get(score.relative)
|
||||
if verdict is None:
|
||||
adjusted.append(score)
|
||||
continue
|
||||
origin = _blend(score.origin_score, verdict.origin_score)
|
||||
quality = _blend(score.quality_deficit, verdict.quality_deficit)
|
||||
adjusted.append(
|
||||
FileScore(
|
||||
relative=score.relative,
|
||||
language=score.language,
|
||||
sloc=score.sloc,
|
||||
origin_score=origin,
|
||||
quality_deficit=quality,
|
||||
category=categorize(origin, quality),
|
||||
criticality=criticality_for(score.relative),
|
||||
signals=score.signals,
|
||||
)
|
||||
)
|
||||
return adjusted
|
||||
|
||||
|
||||
async def _cleanup_event(workspace: Path, stage: str) -> WorkerEvent:
|
||||
removed = await asyncio.to_thread(remove_workspace, workspace)
|
||||
message = "Workspace deleted, only the persisted report remains" if removed else "Workspace already absent"
|
||||
return WorkerEvent(KIND_LOG, message, {"stage": stage, "workspace_removed": removed})
|
||||
|
||||
|
||||
async def run_pipeline(source_url: str, workspace: Path, config: WorkerSettings) -> AsyncIterator[WorkerEvent]:
|
||||
yield _stage(STAGE_RESOLVE, f"Resolving source type for {source_url}")
|
||||
if not config.allow_private_hosts and is_private_host(source_url):
|
||||
yield WorkerEvent(KIND_ERROR, "Refusing to analyze private or loopback hosts", {"stage": STAGE_RESOLVE})
|
||||
return
|
||||
resolution = await resolve_source(source_url)
|
||||
yield WorkerEvent(
|
||||
KIND_LOG,
|
||||
f"Source classified as {resolution.kind}",
|
||||
{"stage": STAGE_RESOLVE, "kind_detected": resolution.kind, "url": resolution.url},
|
||||
)
|
||||
|
||||
yield _stage(STAGE_ACQUIRE, f"Acquiring source into workspace ({resolution.kind})")
|
||||
reset_workspace(workspace)
|
||||
dom_snapshots: list[DomSnapshot] = []
|
||||
try:
|
||||
if resolution.kind == KIND_GIT:
|
||||
async for progress in clone_repository(resolution.url, workspace):
|
||||
yield WorkerEvent(KIND_LOG, progress, {"stage": STAGE_ACQUIRE})
|
||||
else:
|
||||
async for progress in crawl_website(
|
||||
resolution.url, workspace, dom_sink=dom_snapshots, allow_private=config.allow_private_hosts
|
||||
):
|
||||
yield WorkerEvent(KIND_LOG, progress, {"stage": STAGE_ACQUIRE})
|
||||
except (RepositoryTooLargeError, CloneFailedError, RuntimeError) as error:
|
||||
yield await _cleanup_event(workspace, STAGE_ACQUIRE)
|
||||
yield WorkerEvent(KIND_ERROR, str(error), {"stage": STAGE_ACQUIRE})
|
||||
return
|
||||
digest = content_hash(workspace)
|
||||
yield WorkerEvent(KIND_LOG, f"Workspace content hash {digest[:16]}", {"stage": STAGE_ACQUIRE, "content_hash": digest})
|
||||
|
||||
yield _stage(STAGE_INVENTORY, "Building file inventory and applying exclusion rules")
|
||||
inventory = build_inventory(workspace)
|
||||
yield WorkerEvent(
|
||||
KIND_LOG,
|
||||
f"{len(inventory.analyzable)} files analyzable, {len(inventory.excluded)} excluded "
|
||||
f"(vendored, generated, binary, lockfiles, oversize)",
|
||||
{
|
||||
"stage": STAGE_INVENTORY,
|
||||
"analyzable": len(inventory.analyzable),
|
||||
"excluded": len(inventory.excluded),
|
||||
"excluded_samples": [
|
||||
{"path": path, "reason": reason} for path, reason in inventory.excluded[:15]
|
||||
],
|
||||
},
|
||||
)
|
||||
if not inventory.analyzable:
|
||||
yield await _cleanup_event(workspace, STAGE_INVENTORY)
|
||||
yield WorkerEvent(KIND_ERROR, "No analyzable source files found in this source", {"stage": STAGE_INVENTORY})
|
||||
return
|
||||
template = detect_template(workspace) if config.template_detection else TemplateEvidence()
|
||||
if template.markers:
|
||||
yield WorkerEvent(
|
||||
KIND_SIGNAL,
|
||||
f"Starter template provenance detected (score {template.score}/100): "
|
||||
+ "; ".join(template.markers[:4]),
|
||||
{"stage": STAGE_INVENTORY, "template_score": template.score, "markers": template.markers},
|
||||
)
|
||||
|
||||
yield _stage(STAGE_STATIC, f"Running static multi-signal analysis on {len(inventory.analyzable)} files")
|
||||
contexts = []
|
||||
for entry in inventory.analyzable:
|
||||
context = load_context(entry, inventory.repo)
|
||||
if context is not None:
|
||||
contexts.append(context)
|
||||
else:
|
||||
yield WorkerEvent(
|
||||
KIND_LOG,
|
||||
f"Skipped {entry.relative}: unreadable content",
|
||||
{"stage": STAGE_STATIC, "path": entry.relative, "reason": "unreadable"},
|
||||
)
|
||||
if not contexts:
|
||||
yield await _cleanup_event(workspace, STAGE_STATIC)
|
||||
yield WorkerEvent(
|
||||
KIND_ERROR,
|
||||
"Insufficient analyzable content: every candidate file is minified, generated or unreadable. "
|
||||
"Abstaining rather than guessing, per methodology.",
|
||||
{"stage": STAGE_STATIC, "candidates": len(inventory.analyzable)},
|
||||
)
|
||||
return
|
||||
baselines = compute_repo_baselines(contexts)
|
||||
yield WorkerEvent(
|
||||
KIND_LOG,
|
||||
f"Repo baselines computed over {baselines.file_count} files "
|
||||
f"(mean indent deviation {baselines.indent_variance_mean:.2f}, mean comment ratio {baselines.comment_ratio_mean:.2f})",
|
||||
{"stage": STAGE_STATIC},
|
||||
)
|
||||
scores: list[FileScore] = []
|
||||
excerpts: dict[str, str] = {}
|
||||
source_media_dir = Path(config.media_dir) if config.media_dir else None
|
||||
sources_persisted = 0
|
||||
for index, context in enumerate(contexts, start=1):
|
||||
score = score_file(context, baselines)
|
||||
scores.append(score)
|
||||
excerpts[score.relative] = context.text[:AI_EXCERPT_CHARS]
|
||||
source_name = None
|
||||
if score.signals and sources_persisted < SOURCE_CAP_FILES:
|
||||
source_name = await asyncio.to_thread(
|
||||
_persist_source, source_media_dir, score.relative, context.text
|
||||
)
|
||||
if source_name:
|
||||
sources_persisted += 1
|
||||
mode = "fingerprint scan" if context.fingerprint_only else "full analysis"
|
||||
yield WorkerEvent(
|
||||
KIND_FILE,
|
||||
f"Checked {score.relative} ({mode}): origin {score.origin_score}, quality deficit {score.quality_deficit}, {score.category}",
|
||||
{
|
||||
"stage": STAGE_STATIC,
|
||||
"path": score.relative,
|
||||
"language": score.language,
|
||||
"sloc": score.sloc,
|
||||
"origin_score": score.origin_score,
|
||||
"quality_deficit": score.quality_deficit,
|
||||
"category": score.category,
|
||||
"fingerprint_only": context.fingerprint_only,
|
||||
"signals": [signal.to_dict() for signal in score.signals[:20]],
|
||||
"source": source_name,
|
||||
},
|
||||
)
|
||||
for signal in score.signals:
|
||||
if signal.severity == SEVERITY_STRONG:
|
||||
yield WorkerEvent(
|
||||
KIND_SIGNAL,
|
||||
f"Strong signal in {score.relative}:{signal.line} - {signal.title}",
|
||||
{"stage": STAGE_STATIC, "path": score.relative, "signal": signal.to_dict()},
|
||||
)
|
||||
if index % PROGRESS_EVERY_FILES == 0 or index == len(contexts):
|
||||
yield WorkerEvent(
|
||||
KIND_PROGRESS,
|
||||
f"Static analysis {index}/{len(contexts)} files",
|
||||
{"stage": STAGE_STATIC, "current": index, "total": len(contexts)},
|
||||
)
|
||||
|
||||
dom_pages = [
|
||||
DomPageContext(
|
||||
url=snapshot.render.final_url,
|
||||
dom=snapshot.dom,
|
||||
console_warnings=snapshot.console_warnings,
|
||||
console_errors=snapshot.console_errors,
|
||||
response_headers=snapshot.response_headers,
|
||||
resource_hosts=snapshot.resource_hosts,
|
||||
screenshot_bytes=snapshot.screenshot_bytes,
|
||||
)
|
||||
for snapshot in dom_snapshots
|
||||
]
|
||||
dom_evidence = aggregate_dom_evidence(dom_pages)
|
||||
dom_media_dir = Path(config.media_dir) if config.media_dir else None
|
||||
if dom_pages:
|
||||
yield _stage(STAGE_DOM, f"Analyzing rendered DOM for AI-builder and AI-slop tells on {len(dom_pages)} page(s)")
|
||||
for page in dom_pages:
|
||||
screenshot_name = None
|
||||
if page.screenshot_bytes is not None and dom_media_dir is not None:
|
||||
screenshot_name = await asyncio.to_thread(
|
||||
persist_screenshot, page.screenshot_bytes, dom_media_dir, page.url
|
||||
)
|
||||
page_signals = run_dom_checks(page)
|
||||
page_builder, page_builder_confidence = detected_builder_for([page])
|
||||
yield WorkerEvent(
|
||||
KIND_DOM,
|
||||
f"DOM analysis of {page.url}: {len(page_signals)} signals, "
|
||||
f"builder {page_builder or 'none'}",
|
||||
{
|
||||
"stage": STAGE_DOM,
|
||||
"url": page.url,
|
||||
"detected_builder": page_builder,
|
||||
"builder_confidence": page_builder_confidence,
|
||||
"signal_count": len(page_signals),
|
||||
"signals": [signal.to_dict() for signal in page_signals[:20]],
|
||||
"screenshot": screenshot_name,
|
||||
},
|
||||
)
|
||||
|
||||
yield _stage(STAGE_AI, "Selecting representative files for AI review")
|
||||
llm = LlmClient(config)
|
||||
samples = select_samples(scores) if llm.review_available else []
|
||||
if not llm.review_available:
|
||||
yield WorkerEvent(
|
||||
KIND_LOG,
|
||||
"AI review disabled or unavailable; static signals remain authoritative",
|
||||
{"stage": STAGE_AI},
|
||||
)
|
||||
yield WorkerEvent(
|
||||
KIND_LOG,
|
||||
f"AI reviewing {len(samples)} representative files, up to {AI_REVIEW_CONCURRENCY} concurrently "
|
||||
f"(deterministic selection, temperature 0)",
|
||||
{"stage": STAGE_AI, "samples": [sample.relative for sample in samples]},
|
||||
)
|
||||
verdicts: list[AiVerdict] = []
|
||||
review_semaphore = asyncio.Semaphore(AI_REVIEW_CONCURRENCY)
|
||||
|
||||
async def review(sample: FileScore) -> tuple[FileScore, AiVerdict | None]:
|
||||
async with review_semaphore:
|
||||
return sample, await classify_file(llm, sample, excerpts.get(sample.relative, ""))
|
||||
|
||||
review_tasks = [asyncio.create_task(review(sample)) for sample in samples]
|
||||
for index, finished in enumerate(asyncio.as_completed(review_tasks), start=1):
|
||||
sample, verdict = await finished
|
||||
if verdict is None:
|
||||
yield WorkerEvent(
|
||||
KIND_LOG,
|
||||
f"AI review unavailable for {sample.relative}, static signals remain authoritative",
|
||||
{"stage": STAGE_AI, "path": sample.relative},
|
||||
)
|
||||
else:
|
||||
verdicts.append(verdict)
|
||||
yield WorkerEvent(
|
||||
KIND_AI,
|
||||
f"AI verdict {sample.relative}: {verdict.category} ({verdict.ai_probability:.0f}% AI)",
|
||||
{
|
||||
"stage": STAGE_AI,
|
||||
"path": verdict.path,
|
||||
"category": verdict.category,
|
||||
"origin_score": verdict.origin_score,
|
||||
"quality_deficit": verdict.quality_deficit,
|
||||
"ai_probability": verdict.ai_probability,
|
||||
"reasoning": verdict.reasoning,
|
||||
"notable_signals": verdict.notable_signals,
|
||||
},
|
||||
)
|
||||
yield WorkerEvent(
|
||||
KIND_PROGRESS,
|
||||
f"AI review {index}/{len(samples)} files",
|
||||
{"stage": STAGE_AI, "current": index, "total": len(samples)},
|
||||
)
|
||||
|
||||
yield _stage(STAGE_IMAGES, "Reviewing images for AI-generated content")
|
||||
image_verdicts: list[ImageVerdict] = []
|
||||
image_stats: dict[str, object] = {"count": 0, "mean_ai_probability": 0.0, "grade": "n/a", "ai_generated_count": 0}
|
||||
if not llm.vision_available:
|
||||
yield WorkerEvent(KIND_LOG, "Vision backend unavailable; skipping image analysis", {"stage": STAGE_IMAGES})
|
||||
else:
|
||||
images = await asyncio.to_thread(collect_images, workspace, config.image_max_count)
|
||||
if not images:
|
||||
yield WorkerEvent(KIND_LOG, "No qualifying images found to review", {"stage": STAGE_IMAGES})
|
||||
else:
|
||||
yield WorkerEvent(
|
||||
KIND_LOG,
|
||||
f"Reviewing {len(images)} images with the vision model, up to {IMAGE_CONCURRENCY} concurrently "
|
||||
f"(deterministic sample, capped at {config.image_max_count})",
|
||||
{"stage": STAGE_IMAGES, "images": [str(path.relative_to(workspace)) for path in images]},
|
||||
)
|
||||
media_dir = Path(config.media_dir) if config.media_dir else None
|
||||
thumbs: dict[str, str | None] = {}
|
||||
for image_path in images:
|
||||
relative = str(image_path.relative_to(workspace))
|
||||
thumb = None
|
||||
if media_dir is not None:
|
||||
thumb = await asyncio.to_thread(make_thumbnail, image_path, media_dir, relative)
|
||||
thumbs[relative] = thumb
|
||||
yield WorkerEvent(
|
||||
KIND_LOG,
|
||||
f"Reviewing image {relative}",
|
||||
{"stage": STAGE_IMAGES, "path": relative, "thumb": thumb},
|
||||
)
|
||||
image_semaphore = asyncio.Semaphore(IMAGE_CONCURRENCY)
|
||||
|
||||
async def review_image(image_path: Path) -> ImageVerdict | None:
|
||||
async with image_semaphore:
|
||||
return await classify_image(llm, workspace, image_path)
|
||||
|
||||
image_tasks = [asyncio.create_task(review_image(image_path)) for image_path in images]
|
||||
for index, finished in enumerate(asyncio.as_completed(image_tasks), start=1):
|
||||
verdict = await finished
|
||||
if verdict is None:
|
||||
continue
|
||||
image_verdicts.append(verdict)
|
||||
yield WorkerEvent(
|
||||
KIND_IMAGE,
|
||||
f"Image {verdict.relative}: grade {verdict.grade}, {verdict.verdict} "
|
||||
f"({verdict.ai_probability:.0f}% AI) - {verdict.image_kind}",
|
||||
{
|
||||
"stage": STAGE_IMAGES,
|
||||
"path": verdict.relative,
|
||||
"ai_probability": verdict.ai_probability,
|
||||
"grade": verdict.grade,
|
||||
"verdict": verdict.verdict,
|
||||
"image_kind": verdict.image_kind,
|
||||
"tells": verdict.tells,
|
||||
"description": verdict.description,
|
||||
"thumb": thumbs.get(verdict.relative),
|
||||
},
|
||||
)
|
||||
yield WorkerEvent(
|
||||
KIND_PROGRESS,
|
||||
f"Image review {index}/{len(images)}",
|
||||
{"stage": STAGE_IMAGES, "current": index, "total": len(images)},
|
||||
)
|
||||
image_stats = image_summary(image_verdicts)
|
||||
yield WorkerEvent(
|
||||
KIND_PROGRESS,
|
||||
f"Image review complete: {image_stats['count']} images, "
|
||||
f"{image_stats['ai_generated_count']} look AI-generated, grade {image_stats['grade']}",
|
||||
{"stage": STAGE_IMAGES, **image_stats},
|
||||
)
|
||||
|
||||
yield _stage(STAGE_SCORE, "Aggregating per-file scores into repository verdict")
|
||||
static_scores = aggregate(scores)
|
||||
blended = bool(verdicts)
|
||||
adjusted = apply_ai_verdicts(scores, verdicts) if blended else scores
|
||||
final_scores = aggregate(adjusted)
|
||||
image_influenced = False
|
||||
if image_verdicts:
|
||||
final_scores = adjust_for_images(final_scores, float(image_stats["mean_ai_probability"]))
|
||||
image_influenced = True
|
||||
final_scores = adjust_for_dom_signals(final_scores, dom_evidence)
|
||||
final_scores = adjust_for_template(final_scores, template.score)
|
||||
score_payload = {
|
||||
"stage": STAGE_SCORE,
|
||||
"static": repo_scores_to_dict(static_scores),
|
||||
"blended_with_ai": blended,
|
||||
"template_score": template.score,
|
||||
"template_markers": template.markers,
|
||||
**repo_scores_to_dict(final_scores),
|
||||
"files_total": len(inventory.analyzable) + len(inventory.excluded),
|
||||
"files_analyzed": len(scores),
|
||||
"content_hash": digest,
|
||||
"source_kind": resolution.kind,
|
||||
"image_review": image_stats,
|
||||
"image_influenced": image_influenced,
|
||||
"detected_builder": dom_evidence.detected_builder,
|
||||
"dom_slop_score": dom_evidence.score,
|
||||
}
|
||||
yield WorkerEvent(
|
||||
KIND_SCORE,
|
||||
f"Final verdict: grade {final_scores.grade}, {final_scores.category}, "
|
||||
f"{final_scores.human_percent}% human / {final_scores.ai_percent}% AI, confidence {final_scores.confidence}",
|
||||
score_payload,
|
||||
)
|
||||
|
||||
yield _stage(STAGE_REPORT, "Generating final report with findings")
|
||||
markdown, model_used = await generate_report(
|
||||
llm,
|
||||
source_url,
|
||||
resolution.kind,
|
||||
final_scores,
|
||||
adjusted,
|
||||
verdicts,
|
||||
len(inventory.excluded),
|
||||
image_verdicts,
|
||||
image_stats,
|
||||
template,
|
||||
dom_evidence,
|
||||
)
|
||||
await llm.aclose()
|
||||
yield WorkerEvent(
|
||||
KIND_REPORT,
|
||||
f"Report generated via {model_used} ({len(markdown)} chars)",
|
||||
{"stage": STAGE_REPORT, "markdown": markdown, "model_used": model_used},
|
||||
)
|
||||
yield await _cleanup_event(workspace, STAGE_REPORT)
|
||||
yield WorkerEvent(KIND_DONE, "Analysis complete", score_payload)
|
||||
237
devplacepy/services/jobs/isslop/service.py
Normal file
237
devplacepy/services/jobs/isslop/service.py
Normal file
@ -0,0 +1,237 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy.config import BASE_DIR, ISSLOP_RUNS_DIR, ISSLOP_WORKSPACES_DIR
|
||||
from devplacepy.database import INTERNAL_GATEWAY_URL, get_int_setting, get_setting, internal_gateway_key
|
||||
from devplacepy.services import pubsub
|
||||
from devplacepy.services.base import ConfigField
|
||||
from devplacepy.services.jobs.base import JobService
|
||||
from devplacepy.services.jobs.isslop import store
|
||||
from devplacepy.services.jobs.isslop.acquisition.workspace import remove_workspace, workspace_for
|
||||
from devplacepy.services.jobs.isslop.config import IMAGE_MAX_COUNT, LLM_MODEL, WORKER_TIMEOUT_SECONDS
|
||||
from devplacepy.services.jobs.isslop.events import KIND_DONE, KIND_ERROR, WorkerEvent
|
||||
from devplacepy.services.jobs.isslop.persistence import EventPersister
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WORKER_MODULE = "devplacepy.services.jobs.isslop.worker"
|
||||
STREAM_LIMIT = 16 * 1024 * 1024
|
||||
TOPIC_PREFIX = "public.isslop."
|
||||
|
||||
|
||||
def topic_for(uid: str) -> str:
|
||||
return f"{TOPIC_PREFIX}{uid}"
|
||||
|
||||
|
||||
class IsslopService(JobService):
|
||||
kind = "isslop"
|
||||
title = "AI Usage Analyzer"
|
||||
description = (
|
||||
"Classifies a git repository or website as AI slop, sophisticated AI-assisted work or "
|
||||
"genuine human work: acquires the source in an isolated subprocess, runs a multi-signal "
|
||||
"static analysis plus AI and vision review passes through the internal gateway, streams "
|
||||
"every step live over pub/sub and persists a shareable report with an authenticity badge."
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="isslop", interval_seconds=2)
|
||||
for field in self.config_fields:
|
||||
if field.key == self.timeout_key:
|
||||
field.default = WORKER_TIMEOUT_SECONDS
|
||||
self.config_fields += [
|
||||
ConfigField(
|
||||
"isslop_allow_private",
|
||||
"Allow private hosts",
|
||||
type="bool",
|
||||
default="0",
|
||||
help="Permit analyzing private, loopback and link-local hosts. Keep off in production (SSRF).",
|
||||
group="Analysis",
|
||||
),
|
||||
ConfigField(
|
||||
"isslop_ai_review",
|
||||
"AI review pass",
|
||||
type="bool",
|
||||
default="1",
|
||||
help="Audit representative files with the AI gateway on top of the static engine.",
|
||||
group="Analysis",
|
||||
),
|
||||
ConfigField(
|
||||
"isslop_image_review",
|
||||
"Image review pass",
|
||||
type="bool",
|
||||
default="1",
|
||||
help="Grade images for AI generation with the vision model.",
|
||||
group="Analysis",
|
||||
),
|
||||
ConfigField(
|
||||
"isslop_template_detection",
|
||||
"Template provenance detection",
|
||||
type="bool",
|
||||
default="1",
|
||||
help="Detect starter-template/boilerplate provenance and count shipped scaffold defaults against the authenticity grade.",
|
||||
group="Analysis",
|
||||
),
|
||||
ConfigField(
|
||||
"isslop_image_max",
|
||||
"Max images per analysis",
|
||||
type="int",
|
||||
default=IMAGE_MAX_COUNT,
|
||||
minimum=1,
|
||||
maximum=100,
|
||||
help="Deterministically sampled cap on images sent to the vision model.",
|
||||
group="Analysis",
|
||||
),
|
||||
]
|
||||
|
||||
def run_dir(self, uid: str) -> Path:
|
||||
return ISSLOP_RUNS_DIR / uid
|
||||
|
||||
def _worker_payload(self, job: dict) -> dict:
|
||||
payload = dict(job.get("payload", {}))
|
||||
payload["llm_endpoint"] = INTERNAL_GATEWAY_URL
|
||||
payload["llm_model"] = LLM_MODEL
|
||||
payload["api_key"] = internal_gateway_key()
|
||||
payload["allow_private"] = get_setting("isslop_allow_private", "0") == "1"
|
||||
payload["ai_review"] = get_setting("isslop_ai_review", "1") == "1"
|
||||
payload["image_review"] = get_setting("isslop_image_review", "1") == "1"
|
||||
payload["image_max"] = max(1, get_int_setting("isslop_image_max", IMAGE_MAX_COUNT))
|
||||
payload["media_dir"] = str(store.media_dir_for(job["uid"]))
|
||||
payload["template_detection"] = get_setting("isslop_template_detection", "1") == "1"
|
||||
return payload
|
||||
|
||||
async def _relay(self, persister: EventPersister, event: WorkerEvent) -> None:
|
||||
enriched = await asyncio.to_thread(persister.apply, event)
|
||||
await pubsub.publish(topic_for(persister.analysis_uid), enriched)
|
||||
|
||||
async def process(self, job: dict) -> dict:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
uid = job["uid"]
|
||||
source_url = str(job.get("payload", {}).get("url", ""))
|
||||
actor_kind = "user" if job.get("owner_kind") == "user" else (job.get("owner_kind") or "system")
|
||||
actor_uid = job.get("owner_id") if job.get("owner_kind") == "user" else None
|
||||
persister = EventPersister(uid)
|
||||
store.reset_evidence(uid)
|
||||
store.update_analysis(uid, status="running")
|
||||
await pubsub.publish(
|
||||
topic_for(uid),
|
||||
{
|
||||
"analysis": uid,
|
||||
"seq": 0,
|
||||
"kind": "status",
|
||||
"message": "running",
|
||||
"data": {},
|
||||
"created_at": store.utc_now(),
|
||||
},
|
||||
)
|
||||
|
||||
run_dir = self.run_dir(uid)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
payload_path = run_dir / "payload.json"
|
||||
payload_path.write_text(json.dumps(self._worker_payload(job)), encoding="utf-8")
|
||||
try:
|
||||
workspace = workspace_for(ISSLOP_WORKSPACES_DIR, source_url, uid)
|
||||
except ValueError as error:
|
||||
await self._relay(persister, WorkerEvent(KIND_ERROR, f"Workspace rejected: {error}", {}))
|
||||
shutil.rmtree(run_dir, ignore_errors=True)
|
||||
raise RuntimeError(str(error))
|
||||
|
||||
try:
|
||||
final = await self._run_worker(uid, persister, payload_path, workspace)
|
||||
finally:
|
||||
await asyncio.to_thread(remove_workspace, workspace)
|
||||
shutil.rmtree(run_dir, ignore_errors=True)
|
||||
|
||||
if final.kind == KIND_ERROR:
|
||||
audit.record_system(
|
||||
"isslop.run.failed",
|
||||
actor_kind=actor_kind,
|
||||
actor_uid=actor_uid,
|
||||
result="failure",
|
||||
summary=f"AI usage analysis of {source_url} failed",
|
||||
metadata={"target": source_url, "error": final.message[:200]},
|
||||
links=[audit.job(uid)],
|
||||
)
|
||||
raise RuntimeError(final.message)
|
||||
|
||||
audit.record_system(
|
||||
"isslop.run.complete",
|
||||
actor_kind=actor_kind,
|
||||
actor_uid=actor_uid,
|
||||
summary=f"AI usage analysis of {source_url} graded {final.data.get('grade')}",
|
||||
metadata={
|
||||
"target": source_url,
|
||||
"grade": final.data.get("grade"),
|
||||
"category": final.data.get("category"),
|
||||
"human_percent": final.data.get("human_percent"),
|
||||
"files_analyzed": final.data.get("files_analyzed"),
|
||||
},
|
||||
links=[audit.job(uid)],
|
||||
)
|
||||
return {
|
||||
"target": source_url,
|
||||
"grade": final.data.get("grade"),
|
||||
"category": final.data.get("category"),
|
||||
"human_percent": final.data.get("human_percent"),
|
||||
"ai_percent": final.data.get("ai_percent"),
|
||||
"report_url": f"/tools/isslop/{uid}/report",
|
||||
"bytes_in": 0,
|
||||
"bytes_out": 0,
|
||||
"item_count": int(final.data.get("files_analyzed") or 0),
|
||||
}
|
||||
|
||||
async def _run_worker(
|
||||
self, uid: str, persister: EventPersister, payload_path: Path, workspace: Path
|
||||
) -> WorkerEvent:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
WORKER_MODULE,
|
||||
str(payload_path),
|
||||
str(workspace),
|
||||
cwd=str(BASE_DIR),
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
limit=STREAM_LIMIT,
|
||||
)
|
||||
final: WorkerEvent | None = None
|
||||
stderr_task = asyncio.create_task(proc.stderr.read())
|
||||
try:
|
||||
while True:
|
||||
line = await proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
event = WorkerEvent.parse(line.decode("utf-8", "replace"))
|
||||
if event is None:
|
||||
continue
|
||||
await self._relay(persister, event)
|
||||
if event.kind in (KIND_DONE, KIND_ERROR):
|
||||
final = event
|
||||
await stderr_task
|
||||
await proc.wait()
|
||||
finally:
|
||||
if proc.returncode is None:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
await proc.wait()
|
||||
if not stderr_task.done():
|
||||
stderr_task.cancel()
|
||||
if final is None:
|
||||
final = WorkerEvent(
|
||||
KIND_ERROR, f"Analysis worker exited unexpectedly with code {proc.returncode}", {}
|
||||
)
|
||||
await self._relay(persister, final)
|
||||
return final
|
||||
|
||||
def cleanup(self, job: dict) -> None:
|
||||
shutil.rmtree(self.run_dir(job["uid"]), ignore_errors=True)
|
||||
193
devplacepy/services/jobs/isslop/store.py
Normal file
193
devplacepy/services/jobs/isslop/store.py
Normal file
@ -0,0 +1,193 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.config import ISSLOP_MEDIA_DIR
|
||||
from devplacepy.database import get_table
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TABLE_ANALYSES = "isslop_analyses"
|
||||
TABLE_EVENTS = "isslop_events"
|
||||
TABLE_FILE_RESULTS = "isslop_file_results"
|
||||
TABLE_IMAGE_RESULTS = "isslop_image_results"
|
||||
TABLE_DOM_RESULTS = "isslop_dom_results"
|
||||
TABLE_REPORTS = "isslop_reports"
|
||||
|
||||
EVENT_LIMIT_DEFAULT = 2000
|
||||
EVENT_PAYLOAD_CAP = 60000
|
||||
SIGNALS_CAP = 30000
|
||||
LIST_LIMIT_DEFAULT = 50
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def create_analysis(uid: str, source_url: str, owner_kind: str, owner_id: str) -> dict[str, Any]:
|
||||
row: dict[str, Any] = {
|
||||
"uid": uid,
|
||||
"owner_kind": owner_kind,
|
||||
"owner_id": owner_id,
|
||||
"source_url": source_url,
|
||||
"source_kind": "unknown",
|
||||
"status": "pending",
|
||||
"created_at": utc_now(),
|
||||
"finished_at": None,
|
||||
"content_hash": None,
|
||||
"grade": None,
|
||||
"slop_score": None,
|
||||
"origin_score": None,
|
||||
"quality_deficit_score": None,
|
||||
"human_percent": None,
|
||||
"ai_percent": None,
|
||||
"category": None,
|
||||
"confidence": None,
|
||||
"files_total": 0,
|
||||
"files_analyzed": 0,
|
||||
"error_message_text": None,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
get_table(TABLE_ANALYSES).insert(row)
|
||||
return row
|
||||
|
||||
|
||||
def update_analysis(uid: str, **fields: Any) -> None:
|
||||
fields["uid"] = uid
|
||||
get_table(TABLE_ANALYSES).update(fields, ["uid"])
|
||||
|
||||
|
||||
def get_analysis(uid: str) -> Optional[dict[str, Any]]:
|
||||
row = get_table(TABLE_ANALYSES).find_one(uid=uid, deleted_at=None)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_analyses(owner_kind: str, owner_id: str, limit: int = LIST_LIMIT_DEFAULT) -> list[dict[str, Any]]:
|
||||
rows = get_table(TABLE_ANALYSES).find(
|
||||
owner_kind=owner_kind,
|
||||
owner_id=owner_id,
|
||||
deleted_at=None,
|
||||
order_by=["-created_at"],
|
||||
_limit=limit,
|
||||
)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def claim_guest_analyses(guest_id: str, user_uid: str) -> int:
|
||||
table = get_table(TABLE_ANALYSES)
|
||||
rows = list(table.find(owner_kind="guest", owner_id=guest_id, deleted_at=None))
|
||||
for row in rows:
|
||||
table.update({"uid": row["uid"], "owner_kind": "user", "owner_id": user_uid}, ["uid"])
|
||||
if rows:
|
||||
logger.info("Claimed %d guest isslop analyses for user %s", len(rows), user_uid)
|
||||
return len(rows)
|
||||
|
||||
|
||||
def insert_event(analysis_uid: str, seq: int, kind: str, message: str, payload: str) -> None:
|
||||
get_table(TABLE_EVENTS).insert(
|
||||
{
|
||||
"analysis_uid": analysis_uid,
|
||||
"seq": seq,
|
||||
"kind": kind,
|
||||
"message": message,
|
||||
"payload": payload[:EVENT_PAYLOAD_CAP],
|
||||
"created_at": utc_now(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def events_for(analysis_uid: str, after_seq: int = 0, limit: int = EVENT_LIMIT_DEFAULT) -> list[dict[str, Any]]:
|
||||
rows = get_table(TABLE_EVENTS).find(
|
||||
analysis_uid=analysis_uid,
|
||||
seq={">": after_seq},
|
||||
order_by=["seq"],
|
||||
_limit=limit,
|
||||
)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def insert_file_result(analysis_uid: str, record: dict[str, Any]) -> None:
|
||||
record["analysis_uid"] = analysis_uid
|
||||
get_table(TABLE_FILE_RESULTS).insert(record)
|
||||
|
||||
|
||||
def file_result_for(analysis_uid: str, path: str) -> Optional[dict[str, Any]]:
|
||||
row = get_table(TABLE_FILE_RESULTS).find_one(analysis_uid=analysis_uid, path=path)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def file_results_for(analysis_uid: str) -> list[dict[str, Any]]:
|
||||
rows = get_table(TABLE_FILE_RESULTS).find(analysis_uid=analysis_uid, order_by=["path"])
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def insert_image_result(analysis_uid: str, record: dict[str, Any]) -> None:
|
||||
record["analysis_uid"] = analysis_uid
|
||||
get_table(TABLE_IMAGE_RESULTS).insert(record)
|
||||
|
||||
|
||||
def image_results_for(analysis_uid: str) -> list[dict[str, Any]]:
|
||||
rows = get_table(TABLE_IMAGE_RESULTS).find(analysis_uid=analysis_uid, order_by=["-ai_probability"])
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def insert_dom_result(analysis_uid: str, record: dict[str, Any]) -> None:
|
||||
record["analysis_uid"] = analysis_uid
|
||||
get_table(TABLE_DOM_RESULTS).insert(record)
|
||||
|
||||
|
||||
def dom_results_for(analysis_uid: str) -> list[dict[str, Any]]:
|
||||
rows = get_table(TABLE_DOM_RESULTS).find(analysis_uid=analysis_uid, order_by=["-signal_count"])
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def save_report(analysis_uid: str, markdown: str, model_used: str) -> None:
|
||||
get_table(TABLE_REPORTS).upsert(
|
||||
{
|
||||
"analysis_uid": analysis_uid,
|
||||
"markdown": markdown,
|
||||
"model_used": model_used,
|
||||
"generated_at": utc_now(),
|
||||
},
|
||||
["analysis_uid"],
|
||||
)
|
||||
|
||||
|
||||
def get_report(analysis_uid: str) -> Optional[dict[str, Any]]:
|
||||
row = get_table(TABLE_REPORTS).find_one(analysis_uid=analysis_uid)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def decode_json(field: Any, fallback: Any) -> Any:
|
||||
if not field:
|
||||
return fallback
|
||||
try:
|
||||
parsed = json.loads(field)
|
||||
except (ValueError, TypeError):
|
||||
return fallback
|
||||
return parsed if isinstance(parsed, type(fallback)) else fallback
|
||||
|
||||
|
||||
def media_dir_for(uid: str) -> Path:
|
||||
return ISSLOP_MEDIA_DIR / uid
|
||||
|
||||
|
||||
def reset_evidence(uid: str) -> None:
|
||||
get_table(TABLE_EVENTS).delete(analysis_uid=uid)
|
||||
get_table(TABLE_FILE_RESULTS).delete(analysis_uid=uid)
|
||||
get_table(TABLE_IMAGE_RESULTS).delete(analysis_uid=uid)
|
||||
get_table(TABLE_DOM_RESULTS).delete(analysis_uid=uid)
|
||||
get_table(TABLE_REPORTS).delete(analysis_uid=uid)
|
||||
shutil.rmtree(media_dir_for(uid), ignore_errors=True)
|
||||
|
||||
|
||||
def purge_analysis(uid: str) -> None:
|
||||
reset_evidence(uid)
|
||||
get_table(TABLE_ANALYSES).delete(uid=uid)
|
||||
52
devplacepy/services/jobs/isslop/worker.py
Normal file
52
devplacepy/services/jobs/isslop/worker.py
Normal file
@ -0,0 +1,52 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy.services.jobs.isslop.config import settings_from_payload
|
||||
from devplacepy.services.jobs.isslop.events import KIND_ERROR, WorkerEvent
|
||||
from devplacepy.services.jobs.isslop.pipeline import run_pipeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def emit(event: WorkerEvent) -> None:
|
||||
print(event.to_json(), flush=True)
|
||||
|
||||
|
||||
async def amain(payload_path: Path, workspace: Path) -> int:
|
||||
try:
|
||||
payload = json.loads(payload_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as error:
|
||||
emit(WorkerEvent(KIND_ERROR, f"Worker payload unreadable: {error}", {}))
|
||||
return 1
|
||||
settings = settings_from_payload(payload)
|
||||
source_url = str(payload.get("url", ""))
|
||||
failed = False
|
||||
try:
|
||||
async for event in run_pipeline(source_url, workspace, settings):
|
||||
emit(event)
|
||||
if event.kind == KIND_ERROR:
|
||||
failed = True
|
||||
except (OSError, ValueError, RuntimeError) as error:
|
||||
logger.exception("Pipeline crashed")
|
||||
emit(WorkerEvent(KIND_ERROR, f"Pipeline crashed: {error}", {}))
|
||||
return 1
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
|
||||
if len(sys.argv) != 3:
|
||||
emit(WorkerEvent(KIND_ERROR, "usage: worker <payload_json_path> <workspace_dir>", {}))
|
||||
sys.exit(2)
|
||||
exit_code = asyncio.run(amain(Path(sys.argv[1]), Path(sys.argv[2])))
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
809
devplacepy/static/css/isslop.css
Normal file
809
devplacepy/static/css/isslop.css
Normal file
@ -0,0 +1,809 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
.isslop-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.isslop-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.isslop-header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.6rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.isslop-header p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.isslop-report-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.isslop-report-target code {
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.isslop-report-page section[id] {
|
||||
scroll-margin-top: 80px;
|
||||
}
|
||||
|
||||
.isslop-side-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.isslop-definitions,
|
||||
.isslop-form-card,
|
||||
.isslop-history,
|
||||
.isslop-live-card,
|
||||
.isslop-score-card,
|
||||
.isslop-report-body,
|
||||
.isslop-images,
|
||||
.isslop-dom-pages,
|
||||
.isslop-files,
|
||||
.isslop-badge-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.isslop-definitions-lead {
|
||||
margin: 0 0 14px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.isslop-definition-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.isslop-definition-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.isslop-definition-card p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.isslop-form-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.isslop-input {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.isslop-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.isslop-form-error {
|
||||
margin: 10px 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.isslop-history-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.isslop-history-empty {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.isslop-history-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.isslop-history-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-secondary);
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.isslop-history-link:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.isslop-history-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.isslop-history-source {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.isslop-history-detail {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.isslop-grade-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
color: #ffffff;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.isslop-grade-a { background: #34a853; }
|
||||
.isslop-grade-b { background: #7cb342; }
|
||||
.isslop-grade-c { background: #fbbc04; color: #1a1030; }
|
||||
.isslop-grade-d { background: #fb8c00; }
|
||||
.isslop-grade-f { background: #ea4335; }
|
||||
|
||||
.isslop-live-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.isslop-live-status {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.isslop-progress {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.isslop-progress-bar {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: var(--accent-gradient);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.isslop-progress-failed {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.isslop-feed {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.isslop-feed-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.isslop-feed-stage,
|
||||
.isslop-feed-score {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.isslop-feed-signal { color: var(--warning); }
|
||||
.isslop-feed-error { color: var(--danger); }
|
||||
.isslop-feed-done { color: var(--success); }
|
||||
|
||||
.isslop-feed-loader {
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.isslop-loader-cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 14px;
|
||||
background: var(--accent);
|
||||
animation: isslop-blink 1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
.isslop-loader-text::after {
|
||||
content: "";
|
||||
animation: isslop-dots 1.8s steps(4, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes isslop-blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes isslop-dots {
|
||||
0% { content: ""; }
|
||||
25% { content: "."; }
|
||||
50% { content: ".."; }
|
||||
75% { content: "..."; }
|
||||
}
|
||||
|
||||
.isslop-grade-pending {
|
||||
animation: isslop-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes isslop-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
.isslop-feed-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.isslop-feed-text {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.isslop-score-card {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.isslop-gauge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-secondary);
|
||||
border: 4px solid var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.isslop-gauge.isslop-grade-a { border-color: #34a853; background: var(--bg-secondary); }
|
||||
.isslop-gauge.isslop-grade-b { border-color: #7cb342; background: var(--bg-secondary); }
|
||||
.isslop-gauge.isslop-grade-c { border-color: #fbbc04; background: var(--bg-secondary); color: var(--text-primary); }
|
||||
.isslop-gauge.isslop-grade-d { border-color: #fb8c00; background: var(--bg-secondary); }
|
||||
.isslop-gauge.isslop-grade-f { border-color: #ea4335; background: var(--bg-secondary); }
|
||||
|
||||
.isslop-gauge-grade {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.isslop-gauge-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.isslop-score-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.isslop-score-meta h2 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
word-break: break-all;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.isslop-split {
|
||||
display: flex;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
max-width: 420px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.isslop-split-human {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.isslop-split-ai {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.isslop-facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.isslop-chip {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.isslop-chip-builder {
|
||||
border-color: rgba(234, 67, 53, 0.6);
|
||||
background: rgba(234, 67, 53, 0.85);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.isslop-section-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.isslop-report-body .rendered-content {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.isslop-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.isslop-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.isslop-table th,
|
||||
.isslop-table td {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.isslop-table td:first-child {
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.isslop-signal-chip {
|
||||
display: inline-block;
|
||||
margin: 1px 3px 1px 0;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.isslop-signal-strong {
|
||||
border-color: rgba(234, 67, 53, 0.55);
|
||||
color: #f28b82;
|
||||
background: rgba(234, 67, 53, 0.12);
|
||||
}
|
||||
|
||||
.isslop-signal-medium {
|
||||
border-color: rgba(251, 140, 0, 0.5);
|
||||
color: #ffb74d;
|
||||
background: rgba(251, 140, 0, 0.1);
|
||||
}
|
||||
|
||||
.isslop-signal-weak {
|
||||
border-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.isslop-signal-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 5px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: inherit;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.isslop-metric {
|
||||
display: inline-block;
|
||||
min-width: 44px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.isslop-metric-ok {
|
||||
background: rgba(52, 168, 83, 0.16);
|
||||
color: #81c995;
|
||||
}
|
||||
|
||||
.isslop-metric-warn {
|
||||
background: rgba(251, 188, 4, 0.16);
|
||||
color: #fdd663;
|
||||
}
|
||||
|
||||
.isslop-metric-high {
|
||||
background: rgba(251, 140, 0, 0.18);
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
.isslop-metric-critical {
|
||||
background: rgba(234, 67, 53, 0.18);
|
||||
color: #f28b82;
|
||||
}
|
||||
|
||||
.isslop-cat {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.isslop-cat-human-clean {
|
||||
border-color: rgba(52, 168, 83, 0.5);
|
||||
background: rgba(52, 168, 83, 0.14);
|
||||
color: #81c995;
|
||||
}
|
||||
|
||||
.isslop-cat-human-messy {
|
||||
border-color: rgba(251, 188, 4, 0.5);
|
||||
background: rgba(251, 188, 4, 0.12);
|
||||
color: #fdd663;
|
||||
}
|
||||
|
||||
.isslop-cat-sophisticated-ai {
|
||||
border-color: rgba(251, 140, 0, 0.5);
|
||||
background: rgba(251, 140, 0, 0.14);
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
.isslop-cat-ai-slop {
|
||||
border-color: rgba(234, 67, 53, 0.5);
|
||||
background: rgba(234, 67, 53, 0.16);
|
||||
color: #f28b82;
|
||||
}
|
||||
|
||||
.isslop-image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.isslop-image-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-secondary);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.isslop-image-thumb-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 150px;
|
||||
margin: -12px -12px 6px;
|
||||
padding: 10px;
|
||||
background:
|
||||
linear-gradient(45deg, rgba(255, 255, 255, 0.04) 25%, transparent 25%, transparent 75%, rgba(255, 255, 255, 0.04) 75%),
|
||||
linear-gradient(45deg, rgba(255, 255, 255, 0.04) 25%, transparent 25%, transparent 75%, rgba(255, 255, 255, 0.04) 75%),
|
||||
var(--bg-primary);
|
||||
background-size: 16px 16px, 16px 16px;
|
||||
background-position: 0 0, 8px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.isslop-image-thumb {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.isslop-feed-thumb {
|
||||
height: 28px;
|
||||
max-width: 72px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-primary);
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.isslop-image-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.isslop-image-kind {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.isslop-image-path {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.isslop-page-url {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.isslop-image-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.isslop-badge-preview {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.isslop-snippet {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.isslop-snippet code {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.78rem;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.isslop-report-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.isslop-empty {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.isslop-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.isslop-source-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.isslop-source-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.isslop-source {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.isslop-source-num {
|
||||
width: 1%;
|
||||
min-width: 44px;
|
||||
padding: 0 12px 0 8px;
|
||||
text-align: right;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.isslop-source-num a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.isslop-source-num a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.isslop-source-code {
|
||||
padding: 0 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.isslop-source-hit .isslop-source-code {
|
||||
background: rgba(251, 140, 0, 0.08);
|
||||
border-left: 3px solid var(--warning);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.isslop-source-focus .isslop-source-code {
|
||||
background: rgba(255, 107, 53, 0.14);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.isslop-source-line {
|
||||
scroll-margin-top: 90px;
|
||||
}
|
||||
|
||||
.isslop-source-annotation {
|
||||
padding: 6px 12px 2px;
|
||||
}
|
||||
|
||||
.isslop-source-annotation .isslop-signal-chip {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.isslop-source-link {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--border-light);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.isslop-source-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
a.isslop-signal-chip {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a.isslop-signal-chip:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.isslop-source-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.isslop-side-list {
|
||||
list-style: none;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.isslop-side-list li::before {
|
||||
content: "🔹 ";
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.isslop-report-body .rendered-content ul {
|
||||
list-style: none;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.isslop-report-body .rendered-content ul > li::before {
|
||||
content: "🔸 ";
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.isslop-report-body .rendered-content ol > li::marker {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
129
devplacepy/static/js/components/AppIsslop.js
Normal file
129
devplacepy/static/js/components/AppIsslop.js
Normal file
@ -0,0 +1,129 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
import { Http } from "../Http.js";
|
||||
import { Poller } from "../Poller.js";
|
||||
|
||||
const IDLE_INTERVAL_MS = 15000;
|
||||
const ACTIVE_INTERVAL_MS = 4000;
|
||||
const ACTIVE_STATES = ["pending", "running"];
|
||||
|
||||
export class AppIsslop extends Component {
|
||||
connectedCallback() {
|
||||
this._render();
|
||||
this._form = this.querySelector("[data-isslop-form]");
|
||||
this._input = this.querySelector("[data-isslop-url]");
|
||||
this._button = this.querySelector("[data-isslop-run]");
|
||||
this._error = this.querySelector("[data-isslop-error]");
|
||||
this._list = this.querySelector("[data-isslop-list]");
|
||||
this._empty = this.querySelector("[data-isslop-empty]");
|
||||
this._form.addEventListener("submit", (event) => this._submit(event));
|
||||
this._poller = new Poller(() => this._refresh(), IDLE_INTERVAL_MS, { pauseHidden: true });
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._poller) this._poller.stop();
|
||||
}
|
||||
|
||||
_render() {
|
||||
this.innerHTML = `
|
||||
<section class="isslop-form-card">
|
||||
<form class="isslop-form" data-isslop-form autocomplete="off" aria-label="Analyze a source">
|
||||
<div class="isslop-form-row">
|
||||
<input type="url" name="url" class="isslop-input" data-isslop-url
|
||||
placeholder="https://github.com/owner/repository or https://example.com"
|
||||
required aria-required="true" maxlength="2048"
|
||||
aria-label="Repository or website URL to classify">
|
||||
<button type="submit" class="btn btn-primary isslop-run-btn" data-isslop-run>Classify</button>
|
||||
</div>
|
||||
<p class="isslop-form-error" data-isslop-error hidden role="alert"></p>
|
||||
</form>
|
||||
</section>
|
||||
<section class="isslop-history" aria-label="My analyses">
|
||||
<h2 class="isslop-history-title">My analyses</h2>
|
||||
<p class="isslop-history-empty" data-isslop-empty>No analyses yet. Submit a repository or website above.</p>
|
||||
<ul class="isslop-history-list" data-isslop-list></ul>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
async _submit(event) {
|
||||
event.preventDefault();
|
||||
const url = this._input.value.trim();
|
||||
if (!url) return;
|
||||
this._button.disabled = true;
|
||||
this._error.hidden = true;
|
||||
try {
|
||||
const result = await Http.sendForm("/tools/isslop/run", { url });
|
||||
window.location.href = result.report_url;
|
||||
} catch (error) {
|
||||
this._error.textContent = error.message || "Could not start the analysis.";
|
||||
this._error.hidden = false;
|
||||
this._button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _refresh() {
|
||||
let payload;
|
||||
try {
|
||||
payload = await Http.getJson("/tools/isslop/list");
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
const analyses = payload.analyses || [];
|
||||
this._empty.hidden = analyses.length > 0;
|
||||
this._list.replaceChildren(...analyses.map((row) => this._row(row)));
|
||||
const busy = analyses.some((row) => ACTIVE_STATES.includes(row.status));
|
||||
const interval = busy ? ACTIVE_INTERVAL_MS : IDLE_INTERVAL_MS;
|
||||
if (interval !== this._interval) {
|
||||
this._interval = interval;
|
||||
this._poller.stop();
|
||||
this._poller = new Poller(() => this._refresh(), interval, { pauseHidden: true, immediate: false });
|
||||
}
|
||||
}
|
||||
|
||||
_row(analysis) {
|
||||
const item = document.createElement("li");
|
||||
item.className = "isslop-history-item";
|
||||
const link = document.createElement("a");
|
||||
link.className = "isslop-history-link";
|
||||
link.href = analysis.report_url;
|
||||
const grade = document.createElement("span");
|
||||
const gradeValue = analysis.grade || (ACTIVE_STATES.includes(analysis.status) ? "…" : "?");
|
||||
grade.className = `isslop-grade-badge isslop-grade-${(analysis.grade || "pending").toLowerCase()}`;
|
||||
grade.textContent = gradeValue;
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "isslop-history-meta";
|
||||
const source = document.createElement("span");
|
||||
source.className = "isslop-history-source";
|
||||
source.textContent = analysis.source_url;
|
||||
const detail = document.createElement("span");
|
||||
detail.className = "isslop-history-detail";
|
||||
detail.textContent = this._detailText(analysis);
|
||||
if (analysis.created_at) {
|
||||
const when = document.createElement("time");
|
||||
when.dateTime = analysis.created_at;
|
||||
when.setAttribute("data-dt", "");
|
||||
when.setAttribute("data-dt-mode", "ago");
|
||||
detail.append(" · ", when);
|
||||
}
|
||||
meta.append(source, detail);
|
||||
link.append(grade, meta);
|
||||
item.appendChild(link);
|
||||
return item;
|
||||
}
|
||||
|
||||
_detailText(analysis) {
|
||||
if (analysis.status === "failed") return `failed: ${analysis.error || "unknown error"}`;
|
||||
if (ACTIVE_STATES.includes(analysis.status)) return analysis.status;
|
||||
const parts = [];
|
||||
if (analysis.category) parts.push(analysis.category);
|
||||
if (analysis.human_percent !== null && analysis.human_percent !== undefined) {
|
||||
parts.push(`${analysis.human_percent}% human`);
|
||||
}
|
||||
if (analysis.files_analyzed) parts.push(`${analysis.files_analyzed} files`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("dp-isslop", AppIsslop);
|
||||
146
devplacepy/static/js/components/AppIsslopRun.js
Normal file
146
devplacepy/static/js/components/AppIsslopRun.js
Normal file
@ -0,0 +1,146 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
import { Http } from "../Http.js";
|
||||
import { Poller } from "../Poller.js";
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const FEED_CAP = 600;
|
||||
const RELOAD_DELAY_MS = 1500;
|
||||
const TERMINAL_KINDS = ["done", "error"];
|
||||
|
||||
const KIND_ICONS = {
|
||||
stage: "🧭",
|
||||
log: "📋",
|
||||
file: "📄",
|
||||
signal: "🚩",
|
||||
ai: "🤖",
|
||||
image: "🖼️",
|
||||
progress: "⏳",
|
||||
score: "🏁",
|
||||
report: "📝",
|
||||
error: "❌",
|
||||
done: "✅",
|
||||
status: "📡",
|
||||
};
|
||||
const KIND_ICON_FALLBACK = "🔹";
|
||||
|
||||
export class AppIsslopRun extends Component {
|
||||
connectedCallback() {
|
||||
this._uid = this.attr("uid");
|
||||
this._topic = this.attr("topic");
|
||||
this._eventsUrl = this.attr("events-url", `/tools/isslop/${this._uid}/events`);
|
||||
this._lastSeq = 0;
|
||||
this._finished = false;
|
||||
this._render();
|
||||
this._status = this.querySelector("[data-isslop-status]");
|
||||
this._bar = this.querySelector("[data-isslop-bar]");
|
||||
this._feed = this.querySelector("[data-isslop-feed]");
|
||||
this._subscribed = false;
|
||||
this._subscribe();
|
||||
this._poller = new Poller(() => this._poll(), POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._poller) this._poller.stop();
|
||||
if (this._subscribed && window.app && window.app.pubsub) {
|
||||
window.app.pubsub.unsubscribe(this._topic, this._onFrame);
|
||||
}
|
||||
}
|
||||
|
||||
_render() {
|
||||
this.innerHTML = `
|
||||
<div class="isslop-live-head">
|
||||
<span class="isslop-live-status" data-isslop-status role="status" aria-live="polite">Waiting for the analyzer…</span>
|
||||
</div>
|
||||
<div class="isslop-progress"><div class="isslop-progress-bar" data-isslop-bar></div></div>
|
||||
<ul class="isslop-feed" data-isslop-feed role="log" aria-live="polite"></ul>
|
||||
`;
|
||||
this._loader = document.createElement("li");
|
||||
this._loader.className = "isslop-feed-item isslop-feed-loader";
|
||||
this._loader.setAttribute("aria-hidden", "true");
|
||||
this._loader.innerHTML = '<span class="isslop-loader-cursor"></span><span class="isslop-loader-text">working</span>';
|
||||
this.querySelector("[data-isslop-feed]").appendChild(this._loader);
|
||||
}
|
||||
|
||||
_subscribe() {
|
||||
const app = window.app;
|
||||
if (!this._topic || !app || !app.pubsub || typeof app.pubsub.subscribe !== "function") return;
|
||||
this._onFrame = (frame) => this._apply(frame);
|
||||
app.pubsub.subscribe(this._topic, this._onFrame);
|
||||
this._subscribed = true;
|
||||
}
|
||||
|
||||
async _poll() {
|
||||
if (this._finished) return;
|
||||
let payload;
|
||||
try {
|
||||
payload = await Http.getJson(`${this._eventsUrl}?after=${this._lastSeq}`);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
(payload.events || []).forEach((event) => this._apply(event));
|
||||
if (!this._finished && payload.status === "failed" && this._lastSeq === 0) {
|
||||
this._finish(false, "Analysis failed before producing events.");
|
||||
}
|
||||
}
|
||||
|
||||
_apply(event) {
|
||||
if (!event || typeof event.seq !== "number") return;
|
||||
if (event.seq > 0 && event.seq <= this._lastSeq) return;
|
||||
if (event.seq > 0) this._lastSeq = event.seq;
|
||||
if (event.kind === "stage") this._status.textContent = event.message;
|
||||
if (event.kind === "progress" && event.data && event.data.total) {
|
||||
const percent = Math.min(100, Math.round((event.data.current / event.data.total) * 100));
|
||||
this._bar.style.width = `${percent}%`;
|
||||
}
|
||||
this._append(event);
|
||||
if (TERMINAL_KINDS.includes(event.kind)) {
|
||||
this._finish(event.kind === "done", event.message);
|
||||
}
|
||||
}
|
||||
|
||||
_append(event) {
|
||||
const item = document.createElement("li");
|
||||
item.className = `isslop-feed-item isslop-feed-${event.kind}`;
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "isslop-feed-icon";
|
||||
icon.textContent = KIND_ICONS[event.kind] || KIND_ICON_FALLBACK;
|
||||
const text = document.createElement("span");
|
||||
text.className = "isslop-feed-text";
|
||||
text.textContent = event.message;
|
||||
item.append(icon, text);
|
||||
const thumb = event.data ? event.data.thumb : null;
|
||||
if (thumb && /^[a-f0-9]{16}\.webp$/.test(thumb)) {
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "isslop-feed-thumb";
|
||||
preview.src = `/tools/isslop/${this._uid}/media/${thumb}`;
|
||||
preview.alt = "";
|
||||
preview.loading = "lazy";
|
||||
item.appendChild(preview);
|
||||
}
|
||||
this._feed.appendChild(item);
|
||||
this._feed.appendChild(this._loader);
|
||||
while (this._feed.children.length > FEED_CAP) {
|
||||
this._feed.removeChild(this._feed.firstChild);
|
||||
}
|
||||
this._feed.scrollTop = this._feed.scrollHeight;
|
||||
}
|
||||
|
||||
_finish(success, message) {
|
||||
if (this._finished) return;
|
||||
this._finished = true;
|
||||
if (this._loader) this._loader.remove();
|
||||
if (this._poller) this._poller.stop();
|
||||
this._bar.style.width = "100%";
|
||||
this._bar.classList.toggle("isslop-progress-failed", !success);
|
||||
this._status.textContent = success
|
||||
? "Analysis complete, loading the report…"
|
||||
: message || "Analysis failed.";
|
||||
if (success) {
|
||||
window.setTimeout(() => window.location.reload(), RELOAD_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("dp-isslop-run", AppIsslopRun);
|
||||
363
devplacepy/templates/docs/getting-started-vibing.html.bak
Normal file
363
devplacepy/templates/docs/getting-started-vibing.html.bak
Normal file
@ -0,0 +1,363 @@
|
||||
<div class="docs-content" data-render>
|
||||
# Get started with vibing
|
||||
|
||||
> **Alpha, admin-only.** Vibe coding is a preview feature. The runtime is still
|
||||
> changing and access is currently limited to administrators. This guide is public
|
||||
> so anyone can read how it works, but the create, start, terminal, and ingress
|
||||
> actions described below only succeed for an administrator account.
|
||||
|
||||
**Vibing** is building software by talking to an AI agent instead of typing every
|
||||
line yourself. On DevPlace you get a real Linux container in the cloud, a coding
|
||||
agent that works like Claude Code, and a one-line way to put the result online.
|
||||
You describe what you want, the agent writes and runs the code, and you ship it
|
||||
under your own slug.
|
||||
|
||||
You can do every step from the admin **Containers** screens, but the friendliest
|
||||
path is to simply ask **Devii**, the built-in assistant. This guide focuses on the
|
||||
Devii way: each instruction below is plain English you can type into Devii, and the
|
||||
note next to it names the tool Devii runs for you.
|
||||
|
||||
## What you get
|
||||
|
||||
- A **project** to hold your files (the persistent storage for your work).
|
||||
- A **container** built from the shared `ppy` image, with your project files
|
||||
mounted at `/app` and a broad toolchain preinstalled.
|
||||
- Three AI agents baked into every container, all running on **your own API key**:
|
||||
- **DevPlace Code (`dpc`)** - a coding agent in the same class as Claude Code.
|
||||
- **`botje.py`** - a plug-and-play DevPlace bot you can copy and customise.
|
||||
- **`pagent`** - a minimal, zero-dependency agent for small scripted tasks.
|
||||
- **Ingress**: publish a port from your container to a public URL at `/p/<slug>`.
|
||||
|
||||
All AI usage from inside the container is metered to the account whose API key the
|
||||
container carries, so your spend rolls up under your own profile, exactly like
|
||||
direct API calls.
|
||||
|
||||
## The four steps, the Devii way
|
||||
|
||||
Open Devii from the user menu (the **Devii** item) and type these in order. Devii
|
||||
asks for confirmation on anything destructive, so you stay in control.
|
||||
|
||||
**1. Create a project (storage).**
|
||||
|
||||
> "Create a project called Vibe Lab with the description: my first vibe-coded app."
|
||||
|
||||
Devii calls `create_project`. The project is the home for every file your container
|
||||
produces.
|
||||
|
||||
**2. Attach a container to it.**
|
||||
|
||||
> "In Vibe Lab, create a container named lab that maps port 8000."
|
||||
|
||||
Devii calls `container_create_instance`. The instance runs the shared `ppy` image
|
||||
with your project mounted at `/app`. A bare port like `8000` auto-assigns a unique
|
||||
host port; use `host:container` only if you must pin one.
|
||||
|
||||
**3. Start the container.**
|
||||
|
||||
> "Start the lab container in Vibe Lab."
|
||||
|
||||
Devii calls `container_instance_action` with `action=start`. (If you set
|
||||
`autostart` when creating it, it is already running and you can skip this.)
|
||||
|
||||
**4. Open a terminal.**
|
||||
|
||||
> "Open a terminal in the lab container."
|
||||
|
||||
Devii calls the `open_terminal` action, which opens a floating xterm.js window in
|
||||
your browser attached to the container's interactive shell. From here you run
|
||||
`dpc`, `botje.py`, or anything else.
|
||||
|
||||
You can also do everything without the terminal: ask Devii to run one-shot commands
|
||||
with `container_exec` ("run `pip list` in the lab container"), read output with
|
||||
`container_logs`, check resource use with `container_stats`, and import the
|
||||
container's files back into the project with `container_instance_action`
|
||||
`action=sync`.
|
||||
|
||||
## Inside the container
|
||||
|
||||
- The OS user is always **`pravda`** (uid 1000). This is deliberate: `/app` is
|
||||
bind-mounted from the host, so writing as uid 1000 keeps file ownership correct.
|
||||
- Your working directory is **`/app`**, which is your project's files. Anything you
|
||||
create there can be synced back into the project.
|
||||
- **`apt` and `sudo` work without real root.** `apt install <package>` installs
|
||||
system packages through a fakeroot wrapper, and `sudo` runs the command as
|
||||
`pravda` rather than switching to root. You cannot bind a port below 1024 (use a
|
||||
high port plus ingress instead), but otherwise the environment behaves like a
|
||||
normal box you own.
|
||||
- Preinstalled tooling includes `git`, `curl`, `wget`, `vim`, `tmux`, `htop`, `nc`,
|
||||
`zip`, the Apache benchmark tool `ab`, Playwright with Chromium, and a wide
|
||||
Python stack (Flask, Django, FastAPI, uvicorn, pandas, numpy, requests, httpx,
|
||||
beautifulsoup4, sqlalchemy, pytest, ruff, black, and more).
|
||||
|
||||
## DevPlace Code (dpc)
|
||||
|
||||
`dpc` is **DevPlace Code**, a terminal coding agent that provides the same kind of
|
||||
experience as Claude Code: you give it a task, it reads and writes files, runs
|
||||
commands, fixes what it broke, and iterates until the job is done. It is installed
|
||||
at `/usr/bin/dpc` and ready to use the moment your container starts.
|
||||
|
||||
```bash
|
||||
dpc "build a small FastAPI app in app.py that serves a JSON health check at /"
|
||||
```
|
||||
|
||||
`dpc` reads your API key from the container environment (`PRAVDA_API_KEY`) and talks
|
||||
to the platform AI gateway, so **all of its AI usage is metered through your own
|
||||
account**. There is no separate key to manage and nothing to configure: it is plug
|
||||
and play.
|
||||
|
||||
## Container environment keys
|
||||
|
||||
Every container is launched with these variables already set. Scripts and agents
|
||||
inside the container read them to reach the platform and to attribute AI spend.
|
||||
|
||||
| Variable | What it contains |
|
||||
|----------|------------------|
|
||||
| `PRAVDA_BASE_URL` | The public base URL of this DevPlace instance. |
|
||||
| `PRAVDA_OPENAI_URL` | The AI gateway endpoint, `PRAVDA_BASE_URL` + `/openai/v1`. |
|
||||
| `PRAVDA_API_KEY` | The API key used for every AI call. Spend is metered to this account. |
|
||||
| `PRAVDA_USER_UID` | The DevPlace user id whose identity the container carries. |
|
||||
| `PRAVDA_CONTAINER_NAME` | The instance's name. |
|
||||
| `PRAVDA_CONTAINER_UID` | The instance's unique id. |
|
||||
| `PRAVDA_INGRESS_URL` | The public URL of this container when ingress is set, otherwise empty. |
|
||||
|
||||
The key that lands in `PRAVDA_API_KEY` is resolved in order from the instance's
|
||||
**run-as user**, then its creator, then the project owner. You can point a container
|
||||
at a specific account by asking Devii to set `run_as_uid` when creating or
|
||||
configuring it. The OS user stays `pravda`; only the identity and key change.
|
||||
|
||||
## botje.py - the plug-and-play bot
|
||||
|
||||
`botje.py` (installed at `/usr/bin/botje.py`) is a complete, ready-to-run DevPlace
|
||||
bot. Start it with no arguments and it logs in with your `PRAVDA_API_KEY`, then
|
||||
polls DevPlace for `@mentions` and direct messages and answers each one with a full
|
||||
agent toolset. Give it a task on the command line and it runs that single task and
|
||||
exits.
|
||||
|
||||
```bash
|
||||
python /usr/bin/botje.py # run as a DevPlace bot (polling loop)
|
||||
python /usr/bin/botje.py "summarise the latest news" # run one task and exit
|
||||
```
|
||||
|
||||
**What it can do.** Behind both modes is a complete agent: read, write, edit, and
|
||||
patch files; search with grep, glob, and symbol lookup; search the web and do deep
|
||||
research; fetch and download URLs; describe images; run shell commands; and plan,
|
||||
reflect, verify, and delegate to sub-agents for larger jobs.
|
||||
|
||||
**How it is configured.** Everything comes from the environment, so it is plug and
|
||||
play inside a container:
|
||||
|
||||
| Variable | Effect |
|
||||
|----------|--------|
|
||||
| `PRAVDA_API_KEY` | Auth for both DevPlace and the AI gateway (already set). |
|
||||
| `PRAVDA_BASE_URL` | Which DevPlace instance to talk to (already set). |
|
||||
| `BOT_USERNAME` | The bot's own username, so it ignores its own posts. |
|
||||
| `MENTION_POLL_SECONDS` | How often it checks for mentions (default 30). |
|
||||
| `DM_POLL_SECONDS` | How often it checks for direct messages (default 10). |
|
||||
| `DEVPLACE_MAX_ITERATIONS` | Upper bound on agent steps per task. |
|
||||
|
||||
**Make it your own.** `botje.py` is the reference bot, and it is meant to be
|
||||
forked. Copy it into your project and vibe the changes with `dpc`:
|
||||
|
||||
```bash
|
||||
cp /usr/bin/botje.py /app/mybot.py
|
||||
dpc "in mybot.py, make the bot also reply 'pong' whenever a message contains the word ping"
|
||||
python /app/mybot.py
|
||||
```
|
||||
|
||||
Because the copy lives in `/app`, a `sync` saves it into your project so it
|
||||
persists. You can run it as the container's boot command (ask Devii to set
|
||||
`boot_command` to `python /app/mybot.py`) and add a `restart_policy` so it stays up.
|
||||
|
||||
## Ingress: host your app at /p/<slug>
|
||||
|
||||
Ingress publishes one container port to a public URL on the platform. Once set, your
|
||||
app is reachable at `/p/<slug>` over both HTTP and WebSocket. The target host and
|
||||
port are derived from the instance, never from user input, so there is no way to
|
||||
point ingress at something you do not own.
|
||||
|
||||
Two values control it:
|
||||
|
||||
- **`ingress_slug`** - the public name. Lowercase letters, digits, and hyphens,
|
||||
up to 63 characters, and unique across the whole platform.
|
||||
- **`ingress_port`** - the container port to publish. It must be one of the ports
|
||||
you mapped on the instance. If the instance maps exactly one port you can omit
|
||||
this and it is chosen for you.
|
||||
|
||||
**Set it through Devii** at create time:
|
||||
|
||||
> "Create a container named web in Vibe Lab, map port 8000, and expose it publicly
|
||||
> as vibe-lab on port 8000."
|
||||
|
||||
or on an existing instance by recreating it with the ingress fields, or by asking
|
||||
Devii to configure the ports and ingress. The resulting URL is
|
||||
`PRAVDA_BASE_URL` + `/p/vibe-lab`, which is also placed in the container's
|
||||
`PRAVDA_INGRESS_URL` so your app can self-reference its own public address.
|
||||
|
||||
## Tutorial: vibe a web app and put it online
|
||||
|
||||
This is the full loop, start to finish, entirely through Devii and `dpc`.
|
||||
|
||||
**1. Create the project and an exposed container.** In Devii:
|
||||
|
||||
> "Create a project called Quote Wall. Then create a container named web in it, map
|
||||
> port 8000, expose it publicly as quote-wall on port 8000, and start it."
|
||||
|
||||
Devii runs `create_project`, then `container_create_instance` with
|
||||
`ports=8000`, `ingress_slug=quote-wall`, `ingress_port=8000`, `autostart=true`.
|
||||
|
||||
**2. Open a terminal.**
|
||||
|
||||
> "Open a terminal in the web container."
|
||||
|
||||
**3. Vibe the app with dpc.** In the terminal:
|
||||
|
||||
```bash
|
||||
dpc "create app.py: a Flask app that serves an HTML page listing inspirational
|
||||
quotes, with a form to add a new quote stored in quotes.json. Bind to
|
||||
0.0.0.0 port 8000. Then run it."
|
||||
```
|
||||
|
||||
`dpc` writes `app.py` and `quotes.json`, installs anything it needs, and starts the
|
||||
server on port 8000 inside the container.
|
||||
|
||||
**4. Visit your live app.** Open `PRAVDA_BASE_URL` + `/p/quote-wall` in your browser.
|
||||
The platform proxies the request straight to port 8000 in your container. Add a
|
||||
quote in the form and watch it persist.
|
||||
|
||||
**5. Keep it running and save the work.** Back in Devii:
|
||||
|
||||
> "Set the web container's boot command to `python /app/app.py`, set its restart
|
||||
> policy to unless-stopped, then sync it."
|
||||
|
||||
Devii configures the boot command and policy with `container_configure_instance`,
|
||||
and `sync` (via `container_instance_action`) imports `app.py` and `quotes.json` back
|
||||
into the Quote Wall project so they are saved. Your app now restarts on its own and
|
||||
its source lives in your project.
|
||||
|
||||
That is the whole vibe loop: describe, run, expose, save. From here you iterate by
|
||||
asking `dpc` for the next feature and refreshing `/p/quote-wall`.
|
||||
|
||||
## Tutorial: vibe a custom bot by changing botje
|
||||
|
||||
`botje.py` is the reference bot, and it is built to be changed. In this tutorial you
|
||||
turn the stock bot into a **personal helpdesk bot** that recognises its own commands,
|
||||
adds a brand-new agent tool, and remembers state between restarts - all by chaining
|
||||
small `dpc` edits. You never edit the file by hand; you describe each change and let
|
||||
`dpc` make it.
|
||||
|
||||
The pattern is the same every time:
|
||||
|
||||
1. Copy `botje.py` once into your project.
|
||||
2. Ask `dpc` for one focused change.
|
||||
3. Run the bot and try it from another account.
|
||||
4. Ask `dpc` for the next change.
|
||||
5. When it behaves, set it as the boot command and `sync` to save it.
|
||||
|
||||
**1. Start from a copy.** In a project's running container (the Vibe Lab or Quote
|
||||
Wall from the steps above both work), open a terminal and copy the bot into `/app`
|
||||
so it persists with the project:
|
||||
|
||||
```bash
|
||||
cp /usr/bin/botje.py /app/helpdesk.py
|
||||
```
|
||||
|
||||
**2. Add a custom command.** A command is just a phrase the bot recognises in a
|
||||
mention or DM. Ask `dpc` to add one:
|
||||
|
||||
```bash
|
||||
dpc "in /app/helpdesk.py, add a custom command: when a direct message starts with
|
||||
'!help', reply with a short list of the commands this bot supports. Keep the
|
||||
existing mention and DM behavior intact."
|
||||
```
|
||||
|
||||
`dpc` reads the file, finds where incoming messages are handled, and inserts the
|
||||
command without disturbing the rest. Run it and test from a second account:
|
||||
|
||||
```bash
|
||||
python /app/helpdesk.py
|
||||
```
|
||||
|
||||
DM the bot `!help` from another user and you should get the command list back.
|
||||
|
||||
**3. Give it a brand-new tool (the special functionality).** The bot answers with an
|
||||
agent that has a fixed toolset. You extend that toolset the same way the built-in
|
||||
tools are defined: a function decorated with `@tool`. Describe the tool you want and
|
||||
let `dpc` wire it in:
|
||||
|
||||
```bash
|
||||
dpc "in /app/helpdesk.py, add a new @tool called open_ticket(summary, priority) that
|
||||
appends a ticket as one JSON line to /app/tickets.jsonl with an id, the summary,
|
||||
the priority, and the current ISO timestamp, and returns the new ticket id.
|
||||
Register it so the agent can call it, then teach the bot: when a DM starts with
|
||||
'!ticket ', open a ticket from the rest of the message and reply with the id."
|
||||
```
|
||||
|
||||
Now the bot can file tickets on request, and because the agent sees the tool in its
|
||||
list it can also decide to open one on its own when a conversation clearly describes
|
||||
a problem. Restart and test:
|
||||
|
||||
```bash
|
||||
python /app/helpdesk.py
|
||||
```
|
||||
|
||||
DM `!ticket the login page is slow` and confirm a line lands in
|
||||
`/app/tickets.jsonl`.
|
||||
|
||||
**4. Add memory so it survives restarts.** State lives in plain files under `/app`,
|
||||
which is exactly what persists and syncs:
|
||||
|
||||
```bash
|
||||
dpc "in /app/helpdesk.py, add a !tickets command that reads /app/tickets.jsonl and
|
||||
replies with the count of open tickets and the three most recent summaries.
|
||||
Make the file read tolerant of it not existing yet."
|
||||
```
|
||||
|
||||
**5. Refine the voice.** Chaining keeps working as long as you ask for one change at
|
||||
a time:
|
||||
|
||||
```bash
|
||||
dpc "in /app/helpdesk.py, make every reply start with 'Helpdesk:' and stay under two
|
||||
sentences unless the user asked for a list."
|
||||
```
|
||||
|
||||
**6. Run it on boot and save it.** Once the bot behaves, hand it to the container
|
||||
service. In Devii:
|
||||
|
||||
> "Set this container's boot command to `python /app/helpdesk.py`, set its restart
|
||||
> policy to unless-stopped, then sync it."
|
||||
|
||||
Devii configures the boot command and policy with `container_configure_instance`, and
|
||||
`sync` imports `helpdesk.py` and `tickets.jsonl` back into the project so the whole
|
||||
bot is saved. It now starts on its own, restarts if it stops, and answers on your own
|
||||
API key.
|
||||
|
||||
**Where to take it next.** Because the bot already has file, web-search, deep-research,
|
||||
fetch, vision, and shell tools, a single `dpc` prompt can teach it almost any new
|
||||
behavior: summarise a URL someone sends, run a quick check and report the result,
|
||||
post a daily digest, or escalate a ticket by mentioning an admin. Add one tool or one
|
||||
command per prompt, test, and `sync`. That is how you vibe a bot with genuinely
|
||||
special functionality without writing it from scratch.
|
||||
|
||||
## Limits and safety
|
||||
|
||||
- The feature is in **Alpha** and **admin-only**. Behaviour and limits may change.
|
||||
- Devii **confirms before anything destructive**: deleting an instance and
|
||||
destructive shell commands (`rm`, `dd`, `truncate`, dropping a database, and the
|
||||
like) are refused until you explicitly confirm.
|
||||
- You cannot bind ports below 1024 inside the container. Use a high port and
|
||||
ingress to serve on the public web.
|
||||
- AI usage from `dpc`, `botje.py`, and `pagent` is metered to the API key the
|
||||
container carries. Keep an eye on your usage on your profile.
|
||||
|
||||
## Read next
|
||||
|
||||
- [Devii Assistant](/docs/devii.html) - everything the assistant can do for you.
|
||||
- [DeepSearch](/docs/tools-deepsearch.html) and [SEO Diagnostics](/docs/tools-seo.html) -
|
||||
the other tools you can drive conversationally.
|
||||
{% if is_admin(user) %}
|
||||
- [Container Manager](/docs/services-containers.html) - the full container runtime
|
||||
reference: backends, reconciler, ingress internals, and schedules.
|
||||
- [BotsService](/docs/services-bots.html) and [Bots internals](/docs/bots-internals.html) -
|
||||
how the autonomous bot fleet is built on the same agent.
|
||||
{% endif %}
|
||||
</div>
|
||||
200
devplacepy/templates/docs/isslop-checks.html
Normal file
200
devplacepy/templates/docs/isslop-checks.html
Normal file
@ -0,0 +1,200 @@
|
||||
<nav class="docs-toc" aria-label="Contents">
|
||||
<div class="docs-toc-heading">Contents</div>
|
||||
<ul class="docs-toc-grid">
|
||||
<li class="docs-toc-item"><a href="#how-the-check-works"><span class="docs-toc-title">How the check works<span class="docs-toc-count">5 checks</span></span><span class="docs-toc-summary">A plain-language overview of what the AI Usage Analyzer does and what the score means.</span></a></li>
|
||||
<li class="docs-toc-item"><a href="#tell-tale-ai-writing-in-comments-and-text"><span class="docs-toc-title">Tell-tale AI writing in comments and text<span class="docs-toc-count">9 checks</span></span><span class="docs-toc-summary">Phrases and habits that assistants leave behind in code comments and notes.</span></a></li>
|
||||
<li class="docs-toc-item"><a href="#website-look-and-feel-fingerprints"><span class="docs-toc-title">Website look-and-feel fingerprints<span class="docs-toc-count">10 checks</span></span><span class="docs-toc-summary">The recognisable visual recipe that AI website tools reach for by default.</span></a></li>
|
||||
<li class="docs-toc-item"><a href="#ai-website-builders-and-framework-leftovers"><span class="docs-toc-title">AI website builders and framework leftovers<span class="docs-toc-count">5 checks</span></span><span class="docs-toc-summary">Direct fingerprints of the tools that generate whole sites and apps.</span></a></li>
|
||||
<li class="docs-toc-item"><a href="#placeholder-and-unfinished-content"><span class="docs-toc-title">Placeholder and unfinished content<span class="docs-toc-count">5 checks</span></span><span class="docs-toc-summary">Filler text and dead buttons that reveal work was never finished.</span></a></li>
|
||||
<li class="docs-toc-item"><a href="#rendered-page-signals"><span class="docs-toc-title">Rendered page signals<span class="docs-toc-count">8 checks</span></span><span class="docs-toc-summary">What a live look at the actual rendered home page reveals, from builder fingerprints to unfinished build artifacts.</span></a></li>
|
||||
<li class="docs-toc-item"><a href="#security-mistakes-assistants-commonly-make"><span class="docs-toc-title">Security mistakes assistants commonly make<span class="docs-toc-count">12 checks</span></span><span class="docs-toc-summary">The insecure defaults AI tools repeat because they learned them from tutorials.</span></a></li>
|
||||
<li class="docs-toc-item"><a href="#code-structure-and-style-fingerprints"><span class="docs-toc-title">Code structure and style fingerprints<span class="docs-toc-count">9 checks</span></span><span class="docs-toc-summary">The unnaturally tidy, textbook shape that machine-written code tends to have.</span></a></li>
|
||||
<li class="docs-toc-item"><a href="#quality-problems-and-error-handling"><span class="docs-toc-title">Quality problems and error handling<span class="docs-toc-count">7 checks</span></span><span class="docs-toc-summary">Shortcuts and missing safeguards that mark rushed, unreviewed work.</span></a></li>
|
||||
<li class="docs-toc-item"><a href="#dependency-and-correctness-red-flags"><span class="docs-toc-title">Dependency and correctness red flags<span class="docs-toc-count">7 checks</span></span><span class="docs-toc-summary">Imagined libraries, over-complex functions and known risky patterns.</span></a></li>
|
||||
<li class="docs-toc-item"><a href="#ai-generated-images-and-artwork"><span class="docs-toc-title">AI-generated images and artwork<span class="docs-toc-count">8 checks</span></span><span class="docs-toc-summary">How the image reviewer judges whether pictures on a page were made by AI.</span></a></li>
|
||||
<li class="docs-toc-item"><a href="#read-me-and-documentation-smell"><span class="docs-toc-title">Read-me and documentation smell<span class="docs-toc-count">4 checks</span></span><span class="docs-toc-summary">The over-structured, over-eager style of generated project descriptions.</span></a></li>
|
||||
<li class="docs-toc-item"><a href="#starter-template-and-boilerplate-provenance"><span class="docs-toc-title">Starter template and boilerplate provenance<span class="docs-toc-count">4 checks</span></span><span class="docs-toc-summary">Recognising projects that ship a scaffold's defaults instead of original work.</span></a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="docs-content" data-render>
|
||||
# AI Usage Analyzer: every check explained
|
||||
|
||||
The AI Usage Analyzer reads a website or a code repository and estimates how much of it was written by a person and how much was generated by artificial intelligence. This section explains, in plain language, every clue it looks for.
|
||||
|
||||
This page documents, in plain language, every check the [AI Usage Analyzer](/tools/isslop) runs. No detector is definitive on its own: results are calibrated guidance with explicit confidence levels, never proof of provenance. Every heading below is linkable: hover a title and copy its anchor.
|
||||
|
||||
## How the check works
|
||||
|
||||
When you give the AI Usage Analyzer a link, it fetches the page or repository, looks through the readable files, and counts small clues. No single clue is proof. A verdict is only formed when several independent clues agree. The result is a guide, not an accusation.
|
||||
|
||||
- **Two questions, kept separate**: the analyzer asks two different questions about every file. First: does this look like a person or a machine wrote it? Second: is the work carefully finished, or rushed and left messy? Keeping the two questions apart matters, because tidy AI work is not the same as sloppy human work.
|
||||
- **The human percentage**: The headline number is how much of the project appears to be genuinely human-written. A high human percentage is good. A high, easily recognisable amount of AI is what lowers the score.
|
||||
- **The authenticity grade**: The A to F grade is an authenticity grade. It is driven mainly by how much AI usage is detected and secondarily by how many quality problems are found. Even neat AI code lowers the grade, because the badge certifies human authorship.
|
||||
- **Confidence level**: Every result comes with a confidence level of low, medium or high, based on how many strong clues were found and how much readable content there was. Small pages get a low confidence on purpose.
|
||||
- **What is ignored**: Downloaded libraries, automatically generated files, images and other non-code files are set aside before the check, so they cannot unfairly change the result. Compressed program files are still scanned for brand fingerprints even though their style cannot be judged.
|
||||
|
||||
## Tell-tale AI writing in comments and text
|
||||
|
||||
AI assistants have writing habits. When their output is pasted into a project without cleanup, those habits stay in the comments and notes inside the files. These are some of the strongest and clearest clues.
|
||||
|
||||
- **Leftover placeholder notes**: Notes like "your code here" or "rest of the code" mean an unfinished answer was pasted in and never completed.
|
||||
- **Assistant chatter**: Phrases like "certainly!", "here is the updated code" or "I hope this helps" are how a chatbot talks, not how code is normally written.
|
||||
- **AI tool signatures**: Some tools sign their work with lines like "Generated with" a named assistant. That is direct evidence of the tool used.
|
||||
- **Comments that narrate the obvious**: A comment that simply repeats what the next line already says is a habit of machine-written code; people rarely bother.
|
||||
- **Signature AI vocabulary**: Words such as "delve", "showcase", "pivotal", "seamless" and "meticulous", used together, are strongly associated with AI writing.
|
||||
- **Dash overuse**: Assistants use the long dash far more often than most people. A cluster of them is a small but real clue.
|
||||
- **Emoji in comments and headings**: Sprinkling emoji through code comments and buttons is uncommon in careful human work but common in AI output.
|
||||
- **Marketing language**: Boastful phrases like "blazingly fast" or "robust and scalable" inside code read like advertising copy, not engineering notes.
|
||||
- **Edit-narration comments**: Comments like "Updated section" or "Added new feature" are left behind when someone repeatedly asks an assistant to change a page.
|
||||
|
||||
## Website look-and-feel fingerprints
|
||||
|
||||
AI design tools reach for the same visual recipe again and again. When many of these defaults appear together, the page was very likely generated rather than hand-crafted.
|
||||
|
||||
- **The signature purple gradient**: One specific purple-to-violet gradient appears in a huge number of AI-generated pages. It is almost a logo for machine-made design.
|
||||
- **Other default purple tones**: Even without the exact signature, the indigo and violet colours that ship as defaults in popular tools point the same way.
|
||||
- **The default toolkit**: A particular bundle of building blocks (a specific icon set, ready-made components and utility styling) is the standard kit these tools assemble. Finding several together is a strong sign.
|
||||
- **The stock landing-page layout**: Hero banner, three feature cards, testimonials, pricing, frequently-asked-questions: the same running order used by nearly every template.
|
||||
- **Section banner comments**: Big comment labels like "Hero Section" or "Features Section" dividing the page are a generation habit.
|
||||
- **Styling loaded from a shortcut link**: Loading the styling toolkit straight from an internet link is the quick prototype default, not how finished sites are built.
|
||||
- **The default font and icon pairing**: A specific web font paired with a specific icon library is the out-of-the-box combination these tools use.
|
||||
- **Automatic scroll effects**: Ready-made smooth-scrolling and fade-in-on-scroll snippets are copied in wholesale by generators.
|
||||
- **The universal reset block and generic colour variables**: A boilerplate style reset and colours named simply "primary" and "secondary" are textbook scaffolding.
|
||||
- **Emoji inside headings and buttons**: Decorative emoji placed inside page headings and buttons is a common generated-page flourish.
|
||||
|
||||
## AI website builders and framework leftovers
|
||||
|
||||
Some tools build an entire website from a single prompt. They leave behind unmistakable traces in file names, hidden markers and untouched starter text. These are among the most reliable clues of all.
|
||||
|
||||
- **Builder brand markers**: Hidden identifiers and upload folders left by well-known one-prompt website builders are direct evidence of the tool that made the site.
|
||||
- **Build-tool file names**: File and folder names produced automatically by popular app frameworks reveal how the site was assembled, even when the file itself is unreadable machine code.
|
||||
- **Untouched starter pages**: Default titles like "Create Next App" or "Vite + React", the "You need to enable JavaScript" notice, and empty starter containers show a template was never personalised.
|
||||
- **Unrendered template tokens**: Leftover placeholders that were supposed to be filled in automatically, but were shipped as-is, mark hurried generated output.
|
||||
- **Everything crammed into one file**: A single page with hundreds of lines of styling and script all inlined together is the shape of a straight copy-paste from a chat window.
|
||||
|
||||
## Placeholder and unfinished content
|
||||
|
||||
Generated pages are frequently shipped with the sample content still in place. These leftovers show the work was never truly finished for real use.
|
||||
|
||||
- **Sample names, emails and phone numbers**: Fictional contacts like "john@example.com", "Your Company" or a "555" phone number are stand-ins that a real owner would have replaced.
|
||||
- **Latin filler text**: The classic "lorem ipsum" placeholder paragraphs mean the real words were never written.
|
||||
- **Buttons and links that go nowhere**: A page full of links that lead back to the same spot is a mock-up, not a working site.
|
||||
- **Default project names**: Names like "my-app" or "my-project" left in the settings show the starter was never renamed.
|
||||
- **Tutorial startup messages**: Console messages copied straight from a getting-started guide are a small sign of stitched-together code.
|
||||
|
||||
## Rendered page signals
|
||||
|
||||
For a live website, the analyzer goes a step further than reading its files: it opens the home page in a real headless browser and inspects what a visitor's browser actually produces, computed styles, the census of class names in use, meta tags, headings and page structure, console warnings, the hosts behind every loaded resource, and a full-page screenshot. This rendered-page pass only ever runs once, against the home page, and only for a live website: a git repository has nothing to render, and if a browser could not be launched the analyzer simply skips this pass and relies on the file-based checks above. Underneath, these checks are organised into eight categories running forty-nine individual signal checks against the captured page; the list below groups them by theme.
|
||||
|
||||
- **AI website builder fingerprints**: the strongest and most direct evidence in this section. One-prompt website builders leave behind a hidden badge, a loader script, a "generator" meta tag, or a distinctive hosting subdomain. The analyzer recognises Lovable, Bolt.new, Replit, Base44, Framer, Wix, Webflow, GoDaddy, Squarespace and embedded Claude Artifacts by name, plus a softer corroborating check for a cluster of shadcn/ui and Radix component markers and for hosting on a generic Vercel, Netlify or Databutton subdomain.
|
||||
- **Color and gradient defaults**: the exact purple-to-violet gradient and the handful of indigo and violet accent colours that ship as defaults in popular design tools, an oversized glowing purple shadow, an all-dark default theme, and a colour token in the shadcn/ui default lightness band.
|
||||
- **Typography defaults**: a small set of fonts, Inter, Poppins, Manrope, Geist, Space Grotesk, DM Sans and Plus Jakarta Sans, are the out-of-the-box choice in nearly every AI design tool, especially when the whole rendered page uses only one font family, when a decorative monospace font shows up in a heading or button, or when the page loads the exact canonical Google Fonts weight set these tools request.
|
||||
- **Boilerplate layout shapes**: the hero-features-testimonials-pricing-FAQ section running order, a translucent blurred navigation bar, one class name repeated across many near-identical cards, and a page full of rendered links that lead nowhere are the same generated shape seen again and again.
|
||||
- **Cliche marketing copy**: rendered headings and meta text are checked for stock phrases like "unlock the power of" or "take your business to the next level", a cluster of inflated buzzwords, decorative emoji inside a heading, and the same handful of canonical frequently-asked-questions used by countless landing pages.
|
||||
- **Neglected SEO and metadata**: a missing meta description, structured data, social preview image, page language, canonical link or favicon, and the same meta description reused across every page of a site, are the kind of basic housekeeping a real launch usually gets right and a generated page often skips.
|
||||
- **Accessibility shortcuts**: generic or duplicated image alt text, a run of images with no alt text at all, and a heading structure that jumps straight from a top-level heading to a much deeper one are quick tells of markup nobody reviewed with real visitors in mind.
|
||||
- **Never-productionized build artifacts**: loading the Tailwind styling toolkit or React straight from a public content-delivery link, a browser console still showing development-build warnings, unhashed script filenames, an unedited default framework page title, and placeholder copy or a placeholder image host left in the page all mean a prototype build shipped as the live site.
|
||||
- **Builder matches decide, everything else only nudges**: a confirmed builder fingerprint is treated as decisive evidence on its own. Every other rendered-page signal in this section moves the overall score by only a small, deliberately cautious amount, and only when several of them show up together. Using shadcn/ui, Tailwind, Inter or any other widely used default is completely ordinary and never raises the score by itself.
|
||||
|
||||
## Security mistakes assistants commonly make
|
||||
|
||||
AI tools learned from years of tutorial code that took shortcuts for the sake of a quick demo. They reproduce those same shortcuts, which turn into real security weaknesses. These carry the most weight in the score.
|
||||
|
||||
- **Passwords and keys written into the code**: Secret keys and passwords typed directly into files are a serious risk, and assistants do this routinely.
|
||||
- **Well-known placeholder secrets**: Stock secret values like "your-secret-key" or "supersecret" are favourites that assistants reuse across projects, which makes them easy to guess.
|
||||
- **Doors left open to any website**: A permissive setting that lets any website talk to the app, combined with sign-in credentials, is the classic generated-code vulnerability.
|
||||
- **Demo login details**: Built-in accounts like "admin123" that were never removed leave an obvious way in.
|
||||
- **Sign-in tokens kept in an unsafe place**: Storing a user's sign-in token where any injected script can read it exposes accounts to takeover.
|
||||
- **Cookies without safety flags**: Sign-in cookies set without the standard protective flags can be stolen or misused.
|
||||
- **Secrets exposed to the public side**: A secret placed where the visitor's browser can read it is effectively published to everyone.
|
||||
- **Master database keys in the browser**: The all-powerful database key reachable from the public page hands full control to anyone who looks.
|
||||
- **Unsafe handling of user input**: Building database queries or web content by gluing raw user input together opens the door to well-known attacks.
|
||||
- **Debug mode left switched on**: Shipping with debugging enabled leaks internal details to the public.
|
||||
- **Insecure resource links**: Loading parts of a secure page over an insecure connection weakens the whole page.
|
||||
- **Default database addresses**: Connection details copied straight from a tutorial show the setup was never made real.
|
||||
|
||||
## Code structure and style fingerprints
|
||||
|
||||
Machine-written code is often too tidy. Its spacing, naming and structure are more uniform and more textbook-perfect than the code people write under real deadlines. That very neatness is a clue.
|
||||
|
||||
- **Unnaturally even spacing**: People vary their spacing and blank lines naturally. Code where every line is spaced with machine regularity stands out.
|
||||
- **A note on top of every single function**: When every function, even trivial ones, carries a tidy description in the same template, that uniformity points to generation.
|
||||
- **Over-explained simple functions**: A one-line function wrapped in a formal description it does not need is a classic assistant habit.
|
||||
- **Extremely long textbook names**: Names like "total_user_input_character_count" are the descriptive-but-impractical style assistants prefer.
|
||||
- **Textbook error message pattern**: The exact "print an error message" style of handling problems is straight out of introductory examples.
|
||||
- **Uniform textbook structure**: Full type labels, a description on every function and a standard program entry point all at once is the hallmark of by-the-book generated code.
|
||||
- **Unnecessary layers of abstraction**: Elaborate structures built for a task that does not need them are a known over-engineering habit of assistants.
|
||||
- **Generic names everywhere**: A high count of vague names like "data", "result" and "temp" in real code suggests little human thought went into naming.
|
||||
- **Mixed naming styles**: Two different naming conventions used side by side for the same kind of thing points to stitched-together sources.
|
||||
|
||||
## Quality problems and error handling
|
||||
|
||||
This group looks at whether the work was finished with care. Missing safeguards and copied-in debugging leftovers show code that was generated and shipped without a proper review.
|
||||
|
||||
- **Errors quietly swallowed**: Code that catches a problem and then ignores it hides real failures and is a frequent generated-code shortcut.
|
||||
- **Catch-everything handlers**: Handling every possible error the same vague way, instead of the specific ones expected, is a tell of unreviewed code.
|
||||
- **Leftover debugging output**: Debugging print-outs and markers scattered through the code were meant to be removed before release.
|
||||
- **Missing clean-up after timers and listeners**: Starting a timer or a listener without ever stopping it is a common oversight in generated interface code.
|
||||
- **Assuming a reply is always valid**: Reading a server reply without checking it is the expected type is a fragile shortcut.
|
||||
- **Files opened without safe closing**: Opening files without the standard safe-closing pattern risks leaks and is against normal practice.
|
||||
- **Dead code that never runs**: Blocks written so they can never actually execute are needless scaffolding left behind.
|
||||
|
||||
## Dependency and correctness red flags
|
||||
|
||||
Assistants sometimes invent things that do not exist or produce needlessly complicated code. These clues point to work that may not even function correctly.
|
||||
|
||||
- **Imagined libraries**: References to outside libraries that are not actually installed anywhere may be invented, a well-documented failure of AI tools.
|
||||
- **Over-complicated functions**: A single function packed with far too many decisions is hard to maintain and often machine-produced.
|
||||
- **Very long functions**: Functions that stretch on for well over a hundred lines usually should have been broken up.
|
||||
- **Copy-pasted repetition**: The same block of logic repeated instead of shared is a smell of quick generation.
|
||||
- **Everything-in-one-file modules**: A single oversized file mixing many unrelated jobs is a known generated anti-pattern.
|
||||
- **Data-fetching done the fragile way**: A common interface mistake, fetching information without protection against retries or duplicate requests, is a frequent AI habit.
|
||||
- **Outdated or loose coding shortcuts**: Old-style declarations and vague catch-all types in modern code point to defaults an assistant reached for.
|
||||
|
||||
## AI-generated images and artwork
|
||||
|
||||
Beyond the code and text, the analyzer looks at the pictures themselves. It examines up to twenty images (chosen evenly across the whole set when there are more) and asks a vision model to judge each one, giving every picture its own A-to-F authenticity grade. All images are reviewed at the same time to keep it fast.
|
||||
|
||||
- **Hands, fingers and faces**: AI pictures still slip up on hands with extra or fused fingers, and on faces with strange teeth, eyes or ears.
|
||||
- **Waxy, too-perfect skin**: Skin that looks plastic, airbrushed or unnaturally smooth, without the natural imperfections of a real photo, is a common giveaway.
|
||||
- **Garbled text in the picture**: Signs, labels and packaging with letters that spell nonsense are one of the clearest signs a picture was generated.
|
||||
- **Lighting and shadows that do not add up**: Shadows falling the wrong way, or some objects casting shadows while others do not, reveal that no real light source existed.
|
||||
- **Warped backgrounds and impossible shapes**: Bent architecture, melted objects and geometry that could not exist in reality are classic generation artifacts.
|
||||
- **The too-perfect, over-smooth look**: A mathematically smooth, over-saturated, dreamlike finish across the whole image is typical of image generators.
|
||||
- **Sensible exceptions**: Ordinary screenshots, logos, diagrams and real product photos are recognised as such and are not mistaken for AI art.
|
||||
- **A grade for every image**: Each reviewed picture receives its own grade and a short explanation, which are kept and shown in the report so you can see exactly why.
|
||||
|
||||
## Read-me and documentation smell
|
||||
|
||||
The read-me file that introduces a project has its own generated style: over-structured, decorated with emoji, padded with badges and written in an over-eager tone.
|
||||
|
||||
- **Generic read-me skeleton**: A stack of emoji-topped headings with the same boilerplate sections and no project-specific detail is a generated-introduction pattern.
|
||||
- **Emoji-topped headings**: Every heading prefixed with an emoji is a strong stylistic tell in project documentation.
|
||||
- **Badge stuffing**: A long row of decorative status badges padding out the top of a read-me is a cosmetic generated habit.
|
||||
- **Signature AI phrasing**: The "it is not just X, it is Y" sentence shape and similar constructions are characteristic of assistant prose.
|
||||
## Starter template and boilerplate provenance
|
||||
|
||||
The canonical definition says slop ships defaults. Those defaults are not only the model's: a
|
||||
project that is an unmodified starter template or kitchen-sink boilerplate ships its scaffold's
|
||||
defaults, so recognisable template provenance counts against the authenticity grade. The evidence
|
||||
is scored and every marker is listed in the report; a project genuinely built on top of a starter,
|
||||
with its own name, its own README and real application code, keeps most of its grade.
|
||||
|
||||
- **Known template identity**: the package manifest still carries a known starter's name, a known
|
||||
template publisher as its author, or scaffold metadata a generator wrote (for example the
|
||||
create-t3-app init record).
|
||||
- **Template marketing in the read-me**: the read-me still says what the template's read-me said:
|
||||
"boilerplate and starter for", "open-source template built with", "bootstrapped with", one-click
|
||||
deploy buttons, hosted demo links and sponsor sections.
|
||||
- **Kitchen-sink scaffold constellation**: a large pile of standard scaffold artifacts (Storybook,
|
||||
Husky, commitlint, lint-staged, Playwright, Vitest, Drizzle, Crowdin, semantic-release and
|
||||
friends) arriving together is the signature of a generated setup, not of a project that grew.
|
||||
- **Confidence gating**: weak evidence changes nothing; only a confident template score lowers the
|
||||
grade, and near-certain evidence lowers it further. The influence is a floor on the defaults
|
||||
share, never a replacement for the code analysis.
|
||||
- **Untouched templates are slop**: when the evidence is near-certain, the verdict category is
|
||||
`ai-slop`, whatever the scaffold's code quality. Shipping defaults as-is is the definition of
|
||||
slop; the curation in a starter belongs to the template author, not to the project presenting
|
||||
it. Confident-but-partial evidence caps the category at `uncertain` instead.
|
||||
|
||||
</div>
|
||||
113
devplacepy/templates/docs/services-containers.html.bak
Normal file
113
devplacepy/templates/docs/services-containers.html.bak
Normal file
@ -0,0 +1,113 @@
|
||||
<div class="docs-content" data-render>
|
||||
# Container Manager
|
||||
|
||||
The Container Manager runs supervised container instances with full lifecycle control from the web UI,
|
||||
the HTTP API, and Devii. Every instance runs ONE shared, prebuilt image; there is no per-project image
|
||||
building inside the app. A reconciling `BaseService` supervises the instances.
|
||||
|
||||
> Audience: administrators. All run, exec, lifecycle, and schedule operations require an administrator,
|
||||
> because running containers with bind mounts and access to the docker socket is root-equivalent on the
|
||||
> host.
|
||||
|
||||
> Visibility scoping: container access inherits the project's visibility through
|
||||
> `content.can_view_project`. A container attached to a project hidden by a **member** is visible to
|
||||
> every administrator, but a container attached to a project hidden by an **administrator** is visible
|
||||
> only to that owner administrator - every other administrator is blocked from listing it, opening a
|
||||
> terminal on it, exec-ing in it, or managing it, on both the web UI and the REST API. The opt-in public
|
||||
> ingress at `/p/{slug}` and the generic admin raw DB API `/dbapi` are deliberate exceptions and are not
|
||||
> scoped this way.
|
||||
|
||||
## Pieces
|
||||
|
||||
- **Backend abstraction** (`services/containers/backend/`): a `Backend` ABC with a `DockerCliBackend`
|
||||
that drives the `docker` CLI via `asyncio.create_subprocess_exec`, streaming stdout line by line. A
|
||||
`FakeBackend` implements the same interface for tests with no daemon. The active backend is resolved
|
||||
by `runtime.get_backend()` and is the pluggable seam for a future Kubernetes or remote backend.
|
||||
- **Data model** (indexed in `database.py`): `instances`, `instance_events` (audit), `instance_metrics`
|
||||
(ring-buffered), `instance_schedules`. There are no dockerfile or build tables.
|
||||
- **ContainerService** (`service.py`): a reconciler. Each tick it snapshots `docker ps` (filtered by
|
||||
the `devplace.instance` label), converges every instance to its `desired_state`, applies restart
|
||||
policies, reaps orphan containers whose DB row is gone, fires due schedules, and samples metrics. The
|
||||
`devplace.instance=<uid>` label is the join key, guaranteeing no orphans and no lost state. Only the
|
||||
service lock owner reconciles; HTTP handlers only edit `desired_state`.
|
||||
|
||||
## The shared `ppy` image
|
||||
|
||||
Every instance runs `ppy:latest` (override with the `DEVPLACE_CONTAINER_IMAGE` env var), built **once**
|
||||
with `make ppy` from `ppy.Dockerfile`. The image is a Python + Playwright base with a broad set of
|
||||
common Python libraries preinstalled, and it bakes in the security model: the `pravda` user at uid/gid
|
||||
`1000:1000`, a `sudo` superclone that never escalates (it runs commands as `pravda`, so a container can
|
||||
never write a root-owned file into the bind-mounted workspace), and the stdlib `pagent` agent at
|
||||
`/usr/bin/pagent.py`. Creating an instance is then an instant `docker run` with no build wait; it fails
|
||||
fast with a clear error if the `ppy` image has not been built yet.
|
||||
|
||||
Projects that need a library not in the image install it at runtime with `pip install` (pravda owns the
|
||||
site-packages, so no `sudo` is needed), or add it to `ppy.Dockerfile` and rerun `make ppy`.
|
||||
|
||||
## Instances and lifecycle
|
||||
|
||||
An instance is created with a name, optional boot command, env vars, CPU/memory limits, port maps, and
|
||||
restart policy. The project's files are materialized to a persistent host workspace and bind-mounted
|
||||
read-write as `/app`; the sync action imports container-side changes back into the project filesystem.
|
||||
Lifecycle operations (start, stop, restart, pause, resume, delete) flip the instance's `desired_state`
|
||||
and the reconciler executes them. Schedules use the shared cron/interval/one-time scheduler to start or
|
||||
stop instances. One-shot exec runs over HTTP; an interactive shell runs over a PTY-backed WebSocket on
|
||||
the lock owner.
|
||||
|
||||
Each launch also injects per-instance `PRAVDA_*` env vars (`PRAVDA_BASE_URL`, `PRAVDA_OPENAI_URL`,
|
||||
`PRAVDA_API_KEY`, `PRAVDA_USER_UID`, `PRAVDA_CONTAINER_NAME`, `PRAVDA_CONTAINER_UID`) so code inside the
|
||||
container - and `pagent` - can call back into the platform and the AI gateway authenticated as the user.
|
||||
|
||||
## Runtime data and operations
|
||||
|
||||
All generated data lives OUTSIDE the `devplacepy/` package and is never served via `/static`:
|
||||
persistent workspaces under `config.DATA_DIR/container_workspaces` (default `data/`, configurable with
|
||||
the `DEVPLACE_DATA_DIR` env var - point it at a volume in production). The docker daemon must be able to
|
||||
bind-mount `DATA_DIR` for the `/app` mount. The service is disabled by default (it needs the docker
|
||||
socket); an administrator enables **Containers** on `/admin/services`, and the `ppy` image must be built
|
||||
once with `make ppy`.
|
||||
|
||||
## Publishing a service (ingress)
|
||||
|
||||
A running instance can be exposed by setting an `ingress_slug` plus an `ingress_port` (one of its
|
||||
mapped container ports). The `/p/<slug>` route (`routers/proxy.py`) then reverse-proxies both HTTP and
|
||||
WebSocket traffic to that container's published host port, stripping the `/p/<slug>` prefix. It is
|
||||
public but SSRF-safe: the target host and port are derived from the instance row, never from the
|
||||
request. A container WebSocket server on port 8899 becomes reachable at `wss://<your-host>/p/<slug>/ws`.
|
||||
|
||||
Set the **Public URL** in admin settings (`/admin/settings`) to your production origin; the container
|
||||
tools then return an absolute `ingress_url` (for example `https://your-host/p/<slug>`) so Devii and API
|
||||
clients use the real public address rather than localhost.
|
||||
|
||||
## Production
|
||||
|
||||
The manager drives the host docker daemon, so production needs the opt-in overlay
|
||||
`docker-compose.containers.yml`. The `make docker-build` and `make docker-up` targets apply it
|
||||
automatically and self-derive its inputs, so it works with no `sudo`, no `/srv` dir, and no manual
|
||||
`.env` editing; a plain `docker compose up -d` drops the overlay and re-breaks it. The overlay mounts
|
||||
the docker socket (root on the host - trusted admins only), installs the docker CLI via the
|
||||
`INSTALL_DOCKER_CLI` build arg, and adds the host docker group (gid read from the socket via
|
||||
`stat -c '%g' /var/run/docker.sock`). Two docker-in-docker details matter. First, the data dir must be
|
||||
bind-mounted at the **same absolute path** on host and in the app container (make sets
|
||||
`DEVPLACE_DATA_DIR` to the project's own `./data`) because `docker run -v` resolves the source against
|
||||
the host. Second, the ingress proxy reaches a published port through the container's own docker bridge
|
||||
gateway plus the published host port: the reconciler records each instance's `container_ip` and
|
||||
`container_gateway` from `docker inspect`, and the `/p/<slug>` proxy dials `gateway:host_port` by
|
||||
default. This avoids the loopback path, which fails on hosts where docker's `nat OUTPUT` rule excludes
|
||||
`127.0.0.0/8` from DNAT and no `docker-proxy` binds loopback for a `0.0.0.0`-published port. It also
|
||||
avoids the container's own bridge IP, which docker's bridge-isolation rule
|
||||
(`DOCKER ! -i docker0 -o docker0 -j DROP`) can drop from the host. Set
|
||||
`DEVPLACE_CONTAINER_PROXY_HOST` to override this (a containerized app via the docker
|
||||
socket uses `host.docker.internal`); the proxy then dials that host with the published port
|
||||
instead of the gateway. Before the reconciler has recorded a gateway, the proxy falls back to
|
||||
`127.0.0.1`. Run `make ppy` on the production host too. Full steps are in the README
|
||||
"Container Manager wiring".
|
||||
|
||||
## Observability, CLI, and Devii
|
||||
|
||||
The service tile on `/admin/services` shows instance counts; per-instance aggregates (CPU/memory, p95
|
||||
runtime) are computed on read over the ring buffer. The CLI exposes
|
||||
`devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a
|
||||
one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables). Devii's
|
||||
admin `container_*` tools cover the same instance operations in natural language.
|
||||
</div>
|
||||
114
devplacepy/templates/docs/tools-isslop.html
Normal file
114
devplacepy/templates/docs/tools-isslop.html
Normal file
@ -0,0 +1,114 @@
|
||||
<div class="docs-content" data-render>
|
||||
# AI Usage Analyzer
|
||||
|
||||
The AI Usage Analyzer classifies source code and websites as AI slop, sophisticated AI-assisted work,
|
||||
or genuine human work. It exists to end the practice of calling projects slop without evidence:
|
||||
every verdict is produced by a transparent, reproducible pipeline and published as a persistent
|
||||
report anyone can inspect.
|
||||
|
||||
Open it from the **Tools** menu, or go straight to `/tools/isslop`. It is public: you do not need
|
||||
an account. Signed-in members keep their analysis history on their account; as a guest your
|
||||
history is bound to your browser session, and it is claimed by your account automatically the
|
||||
first time you visit the tool while signed in.
|
||||
|
||||
## The three verdicts
|
||||
|
||||
The whole difference in one line: **slop ships the model's defaults, sophisticated AI ships the
|
||||
engineer's decisions, and human ships what no model would ever write.** This is the canonical
|
||||
definition; every check and every grade traces back to it.
|
||||
|
||||
- **ai-slop**: untouched LLM defaults, shipped as-is. Low effort by definition, because the
|
||||
model's defaults are terrible.
|
||||
- **sophisticated-ai**: clear signs of AI, steered by a programmer who knows exactly what they
|
||||
are doing. The model typed; the engineer decided.
|
||||
- **human**: source code no AI would ever write that way. Opinionated, particular, unmistakably
|
||||
someone's.
|
||||
|
||||
Using AI is not the crime; shipping its defaults is. That is why messy human code is explicitly
|
||||
not slop, and why clean, well-curated AI code is explicitly not slop either.
|
||||
|
||||
## Method
|
||||
|
||||
Classification uses a two-axis model derived from published static-analysis research:
|
||||
|
||||
- **Origin score (0-100)**: likelihood the code was AI-generated, computed from stylometric
|
||||
regularity (indentation variance, blank-line ratio, docstring uniformity), narration comments,
|
||||
AI tool signatures, and textbook naming.
|
||||
- **Quality deficit score (0-100)**: a saturating, severity-weighted aggregate of anti-pattern
|
||||
signals: hardcoded secrets, injection patterns, unresolved dependencies, placeholder and
|
||||
prompt-leak comments, swallowed errors, over-abstraction, duplication, convention deviation,
|
||||
and language-specific tells.
|
||||
|
||||
The two axes intersect into five descriptive categories: `ai-slop`, `sophisticated-ai`,
|
||||
`human-clean`, `human-messy`, and `uncertain`. High-quality AI-assisted code is explicitly not
|
||||
slop, and messy human code is explicitly not slop. Vendored, generated, minified, binary, and
|
||||
lockfile content is excluded before analysis; minified bundles and framework build artifacts are
|
||||
still fingerprint-scanned for provenance markers.
|
||||
|
||||
The headline grade is an **authenticity grade**. A high human percentage is positive and a high,
|
||||
recognizable AI-usage percentage is negative, because the badge certifies human authorship. The
|
||||
A-F grade is therefore driven primarily by detected AI usage and secondarily by quality deficit:
|
||||
even clean, well-curated AI code lowers the authenticity grade, and slop (AI plus anti-patterns)
|
||||
lowers it furthest. Per-file scores aggregate into a repository verdict weighted by SLOC and path
|
||||
criticality, with a confidence band.
|
||||
|
||||
**Template provenance is part of the verdict.** A repository that is a recognisable, unmodified
|
||||
starter template or boilerplate (known template identity in its manifest, template marketing in
|
||||
its read-me, a kitchen-sink scaffold constellation) ships its scaffold's defaults, and the
|
||||
defaults share of the verdict rises accordingly - untouched boilerplates grade C-D rather than A and are categorized `ai-slop`, because shipping defaults as-is is the definition of slop no matter how clean the scaffold is.
|
||||
Every marker is listed in the report's Template Provenance section.
|
||||
|
||||
A deterministic selection of representative files receives an additional AI review pass (the files are reviewed concurrently), and the
|
||||
final report is composed by the same model. When no AI backend is reachable the static engine
|
||||
remains fully authoritative.
|
||||
|
||||
Images are reviewed too. Up to twenty images per source are analysed by a vision model
|
||||
(deterministically sampled when there are more, so the result stays stable), all concurrently.
|
||||
Each image gets its own A-to-F authenticity grade and a short explanation of the artifacts found
|
||||
(hands, garbled text, waxy skin, impossible lighting, warped geometry, the over-smooth diffusion
|
||||
look). The mean image AI-likelihood is folded into the overall score when images are present.
|
||||
|
||||
The full plain-language catalog of every check lives on
|
||||
[AI Usage Analyzer: every check explained](/docs/isslop-checks.html).
|
||||
|
||||
## Pipeline
|
||||
|
||||
1. **Resolve**: every URL is probed with `git ls-remote`; git sources are shallow-cloned
|
||||
(depth 1) with an upfront size preflight and a hard 3 GB cancellation guard, other URLs are
|
||||
crawled to a bounded depth with file and byte caps.
|
||||
2. **Inventory**: exclusion rules and language detection.
|
||||
3. **Static analysis**: the multi-signal engine scores every file.
|
||||
4. **AI review**: representative files are audited through the AI gateway.
|
||||
5. **Image review**: qualifying images are graded by the vision model.
|
||||
6. **Scoring**: two-axis aggregation, grade, human/AI percentages.
|
||||
7. **Report**: findings are written to a persistent markdown report.
|
||||
|
||||
The pipeline runs as an isolated subprocess that emits structured events; every event streams
|
||||
live to the page, so you see each file checked, each signal found, and the model reasoning in
|
||||
real time. Identical input content yields consistent output. Source workspaces are deleted as
|
||||
soon as an analysis terminates; only the persisted report and its evidence remain.
|
||||
|
||||
## Running an analysis
|
||||
|
||||
1. Paste a repository URL (`https://github.com/owner/repository`, `git@...`) or a website URL.
|
||||
2. Press **Classify**. The live view opens immediately and streams every step.
|
||||
3. When the analysis finishes, the report page shows the authenticity grade, the human/AI split,
|
||||
the full markdown report, the per-file results, the image review, and the badge embeds.
|
||||
|
||||
You can run one analysis at a time. Targets that resolve to private or local addresses are
|
||||
refused. Completed analyses provide:
|
||||
|
||||
- a persistent, publicly shareable report at `/tools/isslop/{uid}/report`,
|
||||
- a markdown download of the full report at `/tools/isslop/{uid}/report.md`,
|
||||
- a JSON API documented in the [Tools API group](/docs/api.html#tools),
|
||||
- an SVG badge at `/tools/isslop/{uid}/badge.svg` labeled `authenticity`, showing the human score
|
||||
and linking to the report, with ready-to-copy markdown and HTML embed snippets.
|
||||
|
||||
Reports, badges and event trails are intentionally public capability URLs so badge holders can
|
||||
prove their score; only your personal history list is bound to your account or session.
|
||||
|
||||
## Caveats
|
||||
|
||||
No detector is definitive. Results are calibrated guidance with explicit confidence levels,
|
||||
never proof of provenance.
|
||||
</div>
|
||||
86
devplacepy/templates/tools/isslop.html
Normal file
86
devplacepy/templates/tools/isslop.html
Normal file
@ -0,0 +1,86 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/isslop.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="isslop-layout" data-isslop-tool>
|
||||
<aside class="sidebar-card" role="navigation" aria-label="AI Usage Analyzer">
|
||||
<div class="sidebar-heading" role="heading" aria-level="2">AI Usage Analyzer</div>
|
||||
<div class="sidebar-nav">
|
||||
<a href="/tools" class="sidebar-link">
|
||||
<span class="icon">🧰</span>
|
||||
All tools
|
||||
</a>
|
||||
<a href="/tools/isslop" class="sidebar-link active">
|
||||
<span class="icon">🧪</span>
|
||||
AI Usage Analyzer
|
||||
</a>
|
||||
<a href="/docs/isslop-checks.html" class="sidebar-link">
|
||||
<span class="icon">📖</span>
|
||||
About the checks
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-heading">How it grades</div>
|
||||
<ul class="isslop-side-list">
|
||||
<li>Origin score: how AI-like the code is.</li>
|
||||
<li>Quality deficit: anti-patterns and low curation.</li>
|
||||
<li>The A-F grade certifies human authorship.</li>
|
||||
<li>Clean AI code is not slop, but lowers the grade.</li>
|
||||
<li>Messy human code is not slop.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-heading">Tips</div>
|
||||
<ul class="isslop-side-list">
|
||||
<li>Git repositories are shallow-cloned (3 GB cap).</li>
|
||||
<li>Websites render in a real browser after JavaScript.</li>
|
||||
<li>Images get their own AI-generation review.</li>
|
||||
<li>One analysis runs at a time per visitor.</li>
|
||||
<li>Reports and badges are shareable capability URLs.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="isslop-main">
|
||||
<header class="isslop-header">
|
||||
<h1><span class="icon">🧪</span> AI Usage Analyzer</h1>
|
||||
<p>Measure how a codebase or website was made. Every verdict comes from a transparent,
|
||||
reproducible pipeline: static multi-signal analysis, an AI review pass, image forensics
|
||||
and a persistent report with an authenticity badge you can embed. Using AI is not the
|
||||
crime; shipping its defaults is.</p>
|
||||
</header>
|
||||
|
||||
<section class="isslop-definitions" aria-label="The three verdicts">
|
||||
<p class="isslop-definitions-lead">
|
||||
<strong>Slop ships the model's defaults. Sophisticated AI ships the engineer's decisions.
|
||||
Human ships what no model would ever write.</strong>
|
||||
</p>
|
||||
<div class="isslop-definition-grid">
|
||||
<div class="isslop-definition-card">
|
||||
<span class="isslop-cat isslop-cat-ai-slop">ai-slop</span>
|
||||
<p>Untouched LLM defaults, shipped as-is. Low effort by definition, because the
|
||||
model's defaults are terrible.</p>
|
||||
</div>
|
||||
<div class="isslop-definition-card">
|
||||
<span class="isslop-cat isslop-cat-sophisticated-ai">sophisticated-ai</span>
|
||||
<p>Clear signs of AI, steered by a programmer who knows exactly what they are
|
||||
doing. The model typed; the engineer decided.</p>
|
||||
</div>
|
||||
<div class="isslop-definition-card">
|
||||
<span class="isslop-cat isslop-cat-human-clean">human</span>
|
||||
<p>Source code no AI would ever write that way. Opinionated, particular,
|
||||
unmistakably someone's.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<dp-isslop></dp-isslop>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
270
devplacepy/templates/tools/isslop_report.html
Normal file
270
devplacepy/templates/tools/isslop_report.html
Normal file
@ -0,0 +1,270 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/isslop.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="isslop-layout isslop-report-page" data-isslop-report>
|
||||
<aside class="sidebar-card" role="navigation" aria-label="AI Usage Analyzer">
|
||||
<div class="sidebar-heading" role="heading" aria-level="2">AI Usage Analyzer</div>
|
||||
<div class="sidebar-nav">
|
||||
<a href="/tools" class="sidebar-link">
|
||||
<span class="icon">🧰</span>
|
||||
All tools
|
||||
</a>
|
||||
<a href="/tools/isslop" class="sidebar-link">
|
||||
<span class="icon">🧪</span>
|
||||
New analysis
|
||||
</a>
|
||||
<a href="/docs/tools-isslop.html" class="sidebar-link">
|
||||
<span class="icon">📘</span>
|
||||
How it works
|
||||
</a>
|
||||
<a href="/docs/isslop-checks.html" class="sidebar-link">
|
||||
<span class="icon">📖</span>
|
||||
The criteria
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-heading">On this page</div>
|
||||
<div class="sidebar-nav">
|
||||
{% if status == "completed" %}
|
||||
<a href="#verdict" class="sidebar-link">Verdict</a>
|
||||
{% if markdown %}<a href="#report" class="sidebar-link">Report</a>{% endif %}
|
||||
{% if images %}<a href="#images" class="sidebar-link">Image review</a>{% endif %}
|
||||
{% if dom_pages %}<a href="#dom-signals" class="sidebar-link">Rendered page signals</a>{% endif %}
|
||||
{% if files %}<a href="#files" class="sidebar-link">File results</a>{% endif %}
|
||||
<a href="#badge" class="sidebar-link">Authenticity badge</a>
|
||||
{% elif status == "failed" %}
|
||||
<a href="#verdict" class="sidebar-link">Failure details</a>
|
||||
{% else %}
|
||||
<a href="#progress" class="sidebar-link">Live progress</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if status == "completed" %}
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-heading">Export</div>
|
||||
<div class="sidebar-nav">
|
||||
<a href="/tools/isslop/{{ uid }}/report.md" class="sidebar-link">Download markdown</a>
|
||||
<a href="{{ badge.badge_url }}" class="sidebar-link" target="_blank" rel="noopener">Badge SVG</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</aside>
|
||||
|
||||
<div class="isslop-main">
|
||||
<header class="isslop-header">
|
||||
<h1><span class="icon">🧪</span> AI usage analysis report</h1>
|
||||
<div class="isslop-report-target">
|
||||
<span class="isslop-chip">{{ source_kind }}</span>
|
||||
<code>{{ source_url }}</code>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if status == "failed" %}
|
||||
<section id="verdict" class="isslop-empty" role="alert">
|
||||
<p>This analysis <strong>failed</strong>: {{ error or "unknown error" }}.</p>
|
||||
<p>Start a new one from the <a href="/tools/isslop">AI Usage Analyzer</a>.</p>
|
||||
</section>
|
||||
{% elif status != "completed" %}
|
||||
<section id="progress" class="isslop-live-card" aria-label="Analysis progress">
|
||||
<dp-isslop-run uid="{{ uid }}" topic="{{ topic }}" events-url="{{ events_url }}"></dp-isslop-run>
|
||||
</section>
|
||||
{% else %}
|
||||
<section id="verdict" class="isslop-score-card" aria-label="Verdict">
|
||||
<div class="isslop-gauge isslop-grade-{{ (grade or 'pending')|lower }}" role="img"
|
||||
aria-label="Authenticity grade {{ grade }}">
|
||||
<span class="isslop-gauge-grade">{{ grade }}</span>
|
||||
<span class="isslop-gauge-label">authenticity</span>
|
||||
</div>
|
||||
<div class="isslop-score-meta">
|
||||
{% if human_percent is not none %}
|
||||
<div class="isslop-split" role="img"
|
||||
aria-label="{{ human_percent }}% human, {{ ai_percent }}% AI">
|
||||
<span class="isslop-split-human" style="width: {{ human_percent }}%"></span>
|
||||
<span class="isslop-split-ai" style="width: {{ ai_percent }}%"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="isslop-facts">
|
||||
<span class="isslop-cat isslop-cat-{{ category or 'uncertain' }}">{{ category or "uncertain" }}</span>
|
||||
{% if detected_builder %}<span class="isslop-chip isslop-chip-builder">Detected: built with {{ detected_builder }}</span>{% endif %}
|
||||
{% if human_percent is not none %}<span class="isslop-chip">{{ human_percent }}% human</span>{% endif %}
|
||||
{% if ai_percent is not none %}<span class="isslop-chip">{{ ai_percent }}% AI</span>{% endif %}
|
||||
{% if slop_score is not none %}<span class="isslop-chip">slop score {{ slop_score }}</span>{% endif %}
|
||||
{% if origin_score is not none %}<span class="isslop-chip">origin {{ origin_score }}</span>{% endif %}
|
||||
{% if quality_deficit_score is not none %}<span class="isslop-chip">quality deficit {{ quality_deficit_score }}</span>{% endif %}
|
||||
{% if dom_slop_score is not none and dom_slop_score > 0 %}<span class="isslop-chip">rendered-page score {{ dom_slop_score }}</span>{% endif %}
|
||||
<span class="isslop-chip">confidence {{ confidence or "unknown" }}</span>
|
||||
<span class="isslop-chip">{{ files_analyzed }} files analyzed</span>
|
||||
{% if finished_at %}<span class="isslop-chip">analyzed {{ dt_ago(finished_at) }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="isslop-report-actions">
|
||||
<a class="btn btn-secondary" href="/tools/isslop/{{ uid }}/report.md">Download markdown</a>
|
||||
<a class="btn btn-secondary" href="/tools/isslop">New analysis</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if markdown %}
|
||||
<section id="report" class="isslop-report-body" aria-label="Report">
|
||||
<div class="rendered-content">{{ render_content(markdown) }}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if images %}
|
||||
<section id="images" class="isslop-images" aria-label="Image review">
|
||||
<h2 class="isslop-section-title">Image review</h2>
|
||||
<div class="isslop-image-grid">
|
||||
{% for image in images %}
|
||||
<div class="isslop-image-card">
|
||||
{% if image.thumb_url %}
|
||||
<div class="isslop-image-thumb-wrap">
|
||||
<img src="{{ image.thumb_url }}" class="isslop-image-thumb" data-lightbox
|
||||
alt="{{ image.image_kind }}: {{ image.path }}" loading="lazy">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="isslop-image-head">
|
||||
<span class="isslop-grade-badge isslop-grade-{{ image.grade|lower }}">{{ image.grade }}</span>
|
||||
<span class="isslop-image-kind">{{ image.image_kind }}</span>
|
||||
<span class="isslop-chip">{{ image.verdict }} · {{ image.ai_probability|round|int }}% AI</span>
|
||||
</div>
|
||||
<span class="isslop-image-path">{{ image.path }}</span>
|
||||
{% if image.description %}<span class="isslop-image-desc">{{ image.description }}</span>{% endif %}
|
||||
{% if image.tells %}
|
||||
<div class="isslop-facts">
|
||||
{% for tell in image.tells %}<span class="isslop-chip">{{ tell }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if dom_pages %}
|
||||
<section id="dom-signals" class="isslop-dom-pages" aria-label="Rendered page signals">
|
||||
<h2 class="isslop-section-title">Rendered page signals</h2>
|
||||
<div class="isslop-image-grid">
|
||||
{% for page in dom_pages %}
|
||||
<div class="isslop-image-card">
|
||||
{% if page.screenshot_url %}
|
||||
<div class="isslop-image-thumb-wrap">
|
||||
<img src="{{ page.screenshot_url }}" class="isslop-image-thumb" data-lightbox
|
||||
alt="rendered page screenshot: {{ page.url }}" loading="lazy">
|
||||
</div>
|
||||
{% endif %}
|
||||
<h3 class="isslop-page-url">{{ page.url }}</h3>
|
||||
<div class="isslop-facts">
|
||||
{% if page.detected_builder %}<span class="isslop-chip isslop-chip-builder">{{ page.detected_builder }}</span>{% endif %}
|
||||
<span class="isslop-chip">{{ page.signal_count }} signals</span>
|
||||
</div>
|
||||
{% if page.signals %}
|
||||
<div class="isslop-facts">
|
||||
{% for signal in page.signals[:12] %}
|
||||
{% set signal_title = signal.title ~ (" (line " ~ signal.line ~ ")" if signal.line else "") %}
|
||||
<span class="isslop-signal-chip isslop-signal-{{ signal.severity }}" title="{{ signal_title }}">
|
||||
{{- signal.code -}}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% if page.signals|length > 12 %}<span class="isslop-signal-chip">+{{ page.signals|length - 12 }}</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if files %}
|
||||
{% macro metric_level(value, low, mid, high) -%}
|
||||
{%- if value < low %}ok{% elif value < mid %}warn{% elif value < high %}high{% else %}critical{% endif -%}
|
||||
{%- endmacro %}
|
||||
<section id="files" class="isslop-files" aria-label="File results">
|
||||
<h2 class="isslop-section-title">File results</h2>
|
||||
<div class="isslop-table-wrap">
|
||||
<table class="isslop-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Language</th>
|
||||
<th>SLOC</th>
|
||||
<th>Origin</th>
|
||||
<th>Quality deficit</th>
|
||||
<th>Category</th>
|
||||
<th>Signals</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for file in files|sort(attribute="quality_deficit_score", reverse=True) %}
|
||||
<tr>
|
||||
<td>{% if file.source_url %}<a class="isslop-source-link" href="{{ file.source_url }}">{{ file.path }}</a>{% else %}{{ file.path }}{% endif %}</td>
|
||||
<td>{{ file.language }}</td>
|
||||
<td>{{ file.lines }}</td>
|
||||
<td><span class="isslop-metric isslop-metric-{{ metric_level(file.origin_score, 35, 55, 75) }}">{{ file.origin_score }}</span></td>
|
||||
<td><span class="isslop-metric isslop-metric-{{ metric_level(file.quality_deficit_score, 30, 50, 75) }}">{{ file.quality_deficit_score }}</span></td>
|
||||
<td><span class="isslop-cat isslop-cat-{{ file.category }}">{{ file.category }}</span></td>
|
||||
<td>
|
||||
{% for group in file.signal_groups[:4] %}
|
||||
{% set chip_title = group.title ~ (" (" ~ group.count ~ " occurrences, lines " ~ group.lines|join(", ") ~ ")" if group.count > 1 else (" (line " ~ group.lines[0] ~ ")" if group.lines else "")) %}
|
||||
{% if file.source_url and group.lines %}
|
||||
<a class="isslop-signal-chip isslop-signal-{{ group.severity }}"
|
||||
href="{{ file.source_url }}&line={{ group.lines[0] }}#L{{ group.lines[0] }}"
|
||||
title="{{ chip_title }}">
|
||||
{{- group.code -}}
|
||||
{%- if group.count > 1 %}<span class="isslop-signal-count">{{ group.count }}</span>{% endif -%}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="isslop-signal-chip isslop-signal-{{ group.severity }}" title="{{ chip_title }}">
|
||||
{{- group.code -}}
|
||||
{%- if group.count > 1 %}<span class="isslop-signal-count">{{ group.count }}</span>{% endif -%}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if file.signal_groups|length > 4 %}<span class="isslop-signal-chip">+{{ file.signal_groups|length - 4 }}</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section id="badge" class="isslop-badge-panel" aria-label="Authenticity badge">
|
||||
<h2 class="isslop-section-title">Authenticity badge</h2>
|
||||
<div class="isslop-badge-preview">
|
||||
<img src="{{ badge.badge_url }}" alt="authenticity human score badge">
|
||||
</div>
|
||||
<div class="isslop-snippet">
|
||||
<code>{{ badge.markdown }}</code>
|
||||
<button type="button" class="btn btn-secondary" data-copy-text="{{ badge.markdown }}">Copy markdown</button>
|
||||
</div>
|
||||
<div class="isslop-snippet">
|
||||
<code>{{ badge.html }}</code>
|
||||
<button type="button" class="btn btn-secondary" data-copy-text="{{ badge.html }}">Copy HTML</button>
|
||||
</div>
|
||||
<p class="isslop-history-detail">
|
||||
Generated by {{ generator_model or "the static engine" }}{% if generated_at %} {{ dt_ago(generated_at) }}{% endif %}.
|
||||
{% if content_hash %}Content hash {{ content_hash[:16] }}.{% endif %}
|
||||
</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script type="module">
|
||||
document.querySelectorAll("[data-copy-text]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(button.dataset.copyText);
|
||||
if (window.app && window.app.toast) window.app.toast.show("Copied to clipboard");
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
94
devplacepy/templates/tools/isslop_source.html
Normal file
94
devplacepy/templates/tools/isslop_source.html
Normal file
@ -0,0 +1,94 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/isslop.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="isslop-layout isslop-source-page" data-isslop-source>
|
||||
<aside class="sidebar-card" role="navigation" aria-label="AI Usage Analyzer">
|
||||
<div class="sidebar-heading" role="heading" aria-level="2">AI Usage Analyzer</div>
|
||||
<div class="sidebar-nav">
|
||||
<a href="{{ report_url }}" class="sidebar-link">
|
||||
<span class="icon">📄</span>
|
||||
Back to report
|
||||
</a>
|
||||
<a href="/tools/isslop" class="sidebar-link">
|
||||
<span class="icon">🧪</span>
|
||||
New analysis
|
||||
</a>
|
||||
<a href="/docs/isslop-checks.html" class="sidebar-link">
|
||||
<span class="icon">📖</span>
|
||||
The criteria
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if signals %}
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-heading">Findings in this file</div>
|
||||
<div class="sidebar-nav">
|
||||
{% for signal in signals[:12] %}
|
||||
{% if signal.line %}
|
||||
<a href="#L{{ signal.line }}" class="sidebar-link isslop-source-nav">
|
||||
<span class="isslop-signal-chip isslop-signal-{{ signal.severity }}">{{ signal.code }}</span>
|
||||
line {{ signal.line }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</aside>
|
||||
|
||||
<div class="isslop-main">
|
||||
<header class="isslop-header">
|
||||
<h1><span class="icon">📄</span> {{ path }}</h1>
|
||||
<div class="isslop-facts">
|
||||
<span class="isslop-cat isslop-cat-{{ category }}">{{ category }}</span>
|
||||
<span class="isslop-chip">{{ language }}</span>
|
||||
<span class="isslop-chip">origin {{ origin_score }}</span>
|
||||
<span class="isslop-chip">quality deficit {{ quality_deficit_score }}</span>
|
||||
<span class="isslop-chip">{{ source_lines|length }} lines</span>
|
||||
{% if truncated %}<span class="isslop-chip">truncated</span>{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="isslop-source-card" aria-label="Annotated source">
|
||||
<div class="isslop-source-scroll">
|
||||
<table class="isslop-source">
|
||||
<tbody>
|
||||
{% for source_line in source_lines %}
|
||||
{% set lineno = loop.index %}
|
||||
{% set hits = marked_lines.get(lineno) %}
|
||||
{% if hits %}
|
||||
<tr class="isslop-source-annotation-row">
|
||||
<td class="isslop-source-num"></td>
|
||||
<td class="isslop-source-annotation">
|
||||
{% for signal in hits %}
|
||||
<span class="isslop-signal-chip isslop-signal-{{ signal.severity }}"
|
||||
title="{{ signal.evidence or signal.title }}">{{ signal.code }}: {{ signal.title }}</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr id="L{{ lineno }}"
|
||||
class="isslop-source-line{% if hits %} isslop-source-hit{% endif %}{% if lineno == focus_line %} isslop-source-focus{% endif %}">
|
||||
<td class="isslop-source-num"><a href="#L{{ lineno }}">{{ lineno }}</a></td>
|
||||
<td class="isslop-source-code">{{ source_line }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script type="module">
|
||||
const target = window.location.hash && document.querySelector(window.location.hash);
|
||||
if (target) target.scrollIntoView({ block: "center" });
|
||||
</script>
|
||||
{% endblock %}
|
||||
192
tests/api/tools/isslop.py
Normal file
192
tests/api/tools/isslop.py
Normal file
@ -0,0 +1,192 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table, refresh_snapshot
|
||||
|
||||
_counter_isslop = [0]
|
||||
|
||||
|
||||
def _json_headers():
|
||||
return {"Accept": "application/json"}
|
||||
|
||||
|
||||
def _unique(prefix="sl"):
|
||||
_counter_isslop[0] += 1
|
||||
return f"{prefix}{int(time.time() * 1000)}{_counter_isslop[0]}"
|
||||
|
||||
|
||||
def _clear_isslop_data():
|
||||
refresh_snapshot()
|
||||
jobs = get_table("jobs")
|
||||
for row in list(jobs.find(kind="isslop")):
|
||||
jobs.delete(uid=row["uid"])
|
||||
analyses = get_table("isslop_analyses")
|
||||
for row in list(analyses.find()):
|
||||
analyses.delete(uid=row["uid"])
|
||||
|
||||
|
||||
def test_isslop_page_renders(app_server):
|
||||
r = requests.get(f"{BASE_URL}/tools/isslop")
|
||||
assert r.status_code == 200
|
||||
assert "AI Usage Analyzer" in r.text
|
||||
assert "<dp-isslop>" in r.text
|
||||
assert "devii_guest" in r.headers.get("set-cookie", "")
|
||||
|
||||
|
||||
def test_run_enqueues_and_creates_analysis(app_server):
|
||||
session = requests.Session()
|
||||
try:
|
||||
r = session.post(
|
||||
f"{BASE_URL}/tools/isslop/run",
|
||||
headers=_json_headers(),
|
||||
data={"url": "https://github.com/owner/repository"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
uid = body["uid"]
|
||||
assert body["status_url"] == f"/tools/isslop/{uid}"
|
||||
assert body["events_url"] == f"/tools/isslop/{uid}/events"
|
||||
assert body["report_url"] == f"/tools/isslop/{uid}/report"
|
||||
assert body["topic"] == f"public.isslop.{uid}"
|
||||
|
||||
refresh_snapshot()
|
||||
job = get_table("jobs").find_one(uid=uid)
|
||||
assert job is not None
|
||||
assert job["kind"] == "isslop"
|
||||
assert job["status"] == "pending"
|
||||
|
||||
analysis = get_table("isslop_analyses").find_one(uid=uid)
|
||||
assert analysis is not None
|
||||
assert analysis["status"] == "pending"
|
||||
assert analysis["owner_kind"] == "guest"
|
||||
assert analysis["source_url"] == "https://github.com/owner/repository"
|
||||
assert analysis["deleted_at"] is None
|
||||
|
||||
status = session.get(f"{BASE_URL}/tools/isslop/{uid}", headers=_json_headers())
|
||||
assert status.status_code == 200
|
||||
assert status.json()["status"] == "pending"
|
||||
assert status.json()["source_url"] == "https://github.com/owner/repository"
|
||||
|
||||
events = session.get(f"{BASE_URL}/tools/isslop/{uid}/events", headers=_json_headers())
|
||||
assert events.status_code == 200
|
||||
assert events.json()["events"] == []
|
||||
|
||||
badge = session.get(f"{BASE_URL}/tools/isslop/{uid}/badge.svg")
|
||||
assert badge.status_code == 200
|
||||
assert badge.headers["content-type"].startswith("image/svg+xml")
|
||||
assert "analyzing" in badge.text
|
||||
|
||||
listing = session.get(f"{BASE_URL}/tools/isslop/list", headers=_json_headers())
|
||||
assert listing.status_code == 200
|
||||
uids = [row["uid"] for row in listing.json()["analyses"]]
|
||||
assert uid in uids
|
||||
finally:
|
||||
_clear_isslop_data()
|
||||
|
||||
|
||||
def test_second_active_run_is_denied(app_server):
|
||||
session = requests.Session()
|
||||
try:
|
||||
first = session.post(
|
||||
f"{BASE_URL}/tools/isslop/run",
|
||||
headers=_json_headers(),
|
||||
data={"url": "https://github.com/owner/repository"},
|
||||
)
|
||||
assert first.status_code == 200
|
||||
second = session.post(
|
||||
f"{BASE_URL}/tools/isslop/run",
|
||||
headers=_json_headers(),
|
||||
data={"url": "https://github.com/owner/other"},
|
||||
)
|
||||
assert second.status_code == 429
|
||||
assert second.json()["error"]["uid"] == first.json()["uid"]
|
||||
finally:
|
||||
_clear_isslop_data()
|
||||
|
||||
|
||||
def test_invalid_url_is_rejected(app_server):
|
||||
session = requests.Session()
|
||||
try:
|
||||
r = session.post(
|
||||
f"{BASE_URL}/tools/isslop/run",
|
||||
headers=_json_headers(),
|
||||
data={"url": "ftp://example.com/archive"},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code != 200
|
||||
refresh_snapshot()
|
||||
assert get_table("jobs").find_one(kind="isslop") is None
|
||||
finally:
|
||||
_clear_isslop_data()
|
||||
|
||||
|
||||
def test_status_unknown_uid_404(app_server):
|
||||
r = requests.get(f"{BASE_URL}/tools/isslop/does-not-exist", headers=_json_headers())
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_report_json_while_pending(app_server):
|
||||
session = requests.Session()
|
||||
try:
|
||||
run = session.post(
|
||||
f"{BASE_URL}/tools/isslop/run",
|
||||
headers=_json_headers(),
|
||||
data={"url": "https://github.com/owner/repository"},
|
||||
)
|
||||
uid = run.json()["uid"]
|
||||
report = session.get(f"{BASE_URL}/tools/isslop/{uid}/report", headers=_json_headers())
|
||||
assert report.status_code == 200
|
||||
body = report.json()
|
||||
assert body["status"] == "pending"
|
||||
assert body["markdown"] == ""
|
||||
assert body["badge"]["badge_url"].endswith(f"/tools/isslop/{uid}/badge.svg")
|
||||
|
||||
html = session.get(f"{BASE_URL}/tools/isslop/{uid}/report")
|
||||
assert html.status_code == 200
|
||||
assert "dp-isslop-run" in html.text
|
||||
assert 'class="breadcrumb"' in html.text
|
||||
assert "sidebar-card" in html.text
|
||||
|
||||
download = session.get(f"{BASE_URL}/tools/isslop/{uid}/report.md")
|
||||
assert download.status_code == 404
|
||||
finally:
|
||||
_clear_isslop_data()
|
||||
|
||||
|
||||
def test_guest_history_claimed_on_signup(app_server):
|
||||
session = requests.Session()
|
||||
try:
|
||||
run = session.post(
|
||||
f"{BASE_URL}/tools/isslop/run",
|
||||
headers=_json_headers(),
|
||||
data={"url": "https://github.com/owner/repository"},
|
||||
)
|
||||
uid = run.json()["uid"]
|
||||
name = _unique("slopuser")
|
||||
session.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
listing = session.get(f"{BASE_URL}/tools/isslop/list", headers=_json_headers())
|
||||
assert listing.status_code == 200
|
||||
rows = [row for row in listing.json()["analyses"] if row["uid"] == uid]
|
||||
assert len(rows) == 1
|
||||
|
||||
refresh_snapshot()
|
||||
analysis = get_table("isslop_analyses").find_one(uid=uid)
|
||||
user = get_table("users").find_one(username=name)
|
||||
assert analysis["owner_kind"] == "user"
|
||||
assert analysis["owner_id"] == user["uid"]
|
||||
assert get_table("isslop_analyses").count(uid=uid) == 1
|
||||
finally:
|
||||
_clear_isslop_data()
|
||||
45
tests/e2e/tools/isslop.py
Normal file
45
tests/e2e/tools/isslop.py
Normal file
@ -0,0 +1,45 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table, refresh_snapshot
|
||||
|
||||
|
||||
def _clear_isslop_data():
|
||||
refresh_snapshot()
|
||||
jobs = get_table("jobs")
|
||||
for row in list(jobs.find(kind="isslop")):
|
||||
jobs.delete(uid=row["uid"])
|
||||
analyses = get_table("isslop_analyses")
|
||||
for row in list(analyses.find()):
|
||||
analyses.delete(uid=row["uid"])
|
||||
|
||||
|
||||
def test_isslop_page_loads(page, app_server):
|
||||
page.goto(f"{BASE_URL}/tools/isslop", wait_until="domcontentloaded")
|
||||
page.locator("[data-isslop-tool]").wait_for(state="visible")
|
||||
page.locator("[data-isslop-form] input[name='url']").wait_for(state="visible")
|
||||
page.locator("[data-isslop-run]").wait_for(state="visible")
|
||||
page.locator(".isslop-history-title").wait_for(state="visible")
|
||||
|
||||
|
||||
def test_isslop_tools_menu_links_to_page(page, app_server):
|
||||
page.goto(f"{BASE_URL}/tools", wait_until="domcontentloaded")
|
||||
card = page.locator("a[href='/tools/isslop']").first
|
||||
card.wait_for(state="visible")
|
||||
|
||||
|
||||
def test_isslop_submit_opens_live_report(alice):
|
||||
page, _user = alice
|
||||
try:
|
||||
page.goto(f"{BASE_URL}/tools/isslop", wait_until="domcontentloaded")
|
||||
page.locator("[data-isslop-form] input[name='url']").fill(
|
||||
"https://github.com/owner/repository"
|
||||
)
|
||||
page.locator("[data-isslop-run]").click()
|
||||
page.wait_for_url("**/tools/isslop/*/report", wait_until="domcontentloaded")
|
||||
page.locator("dp-isslop-run").wait_for(state="attached")
|
||||
page.locator("[data-isslop-report] .sidebar-card").wait_for(state="visible")
|
||||
page.locator(".breadcrumb").wait_for(state="visible")
|
||||
page.locator(".isslop-feed-loader").wait_for(state="visible")
|
||||
finally:
|
||||
_clear_isslop_data()
|
||||
30
tests/unit/docs_prose.py
Normal file
30
tests/unit/docs_prose.py
Normal file
@ -0,0 +1,30 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.docs_prose import _anchor_headings, heading_slug
|
||||
|
||||
|
||||
def test_heading_slug_normalizes():
|
||||
assert heading_slug("How the check works") == "how-the-check-works"
|
||||
assert heading_slug("Tell-tale AI writing in comments & text") == "tell-tale-ai-writing-in-comments-text"
|
||||
assert heading_slug(" <em>Styled</em> Heading! ") == "styled-heading"
|
||||
assert heading_slug("???") == "section"
|
||||
|
||||
|
||||
def test_anchor_headings_injects_ids_and_permalinks():
|
||||
rendered = _anchor_headings("<h2>First Part</h2><p>x</p><h3>Sub Part</h3>")
|
||||
assert '<h2 id="first-part">' in rendered
|
||||
assert '<h3 id="sub-part">' in rendered
|
||||
assert rendered.count('class="docs-heading-anchor"') == 2
|
||||
assert 'href="#first-part"' in rendered
|
||||
|
||||
|
||||
def test_anchor_headings_deduplicates_slugs():
|
||||
rendered = _anchor_headings("<h2>Setup</h2><h2>Setup</h2><h2>Setup</h2>")
|
||||
assert '<h2 id="setup">' in rendered
|
||||
assert '<h2 id="setup-1">' in rendered
|
||||
assert '<h2 id="setup-2">' in rendered
|
||||
|
||||
|
||||
def test_anchor_headings_leaves_h1_untouched():
|
||||
rendered = _anchor_headings("<h1>Title</h1><h2>Part</h2>")
|
||||
assert "<h1>Title</h1>" in rendered
|
||||
0
tests/unit/routers/tools/__init__.py
Normal file
0
tests/unit/routers/tools/__init__.py
Normal file
69
tests/unit/routers/tools/isslop.py
Normal file
69
tests/unit/routers/tools/isslop.py
Normal file
@ -0,0 +1,69 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.routers.tools.isslop import _signal_groups
|
||||
|
||||
|
||||
def _signal(code, severity="weak", line=1, title="t"):
|
||||
return {"code": code, "title": title, "severity": severity, "axis": "quality", "weight": 1.0, "line": line, "evidence": ""}
|
||||
|
||||
|
||||
def test_signal_groups_deduplicates_with_counts():
|
||||
groups = _signal_groups(
|
||||
[
|
||||
_signal("INJECTION_RISK", "strong", 10),
|
||||
_signal("INJECTION_RISK", "strong", 22),
|
||||
_signal("INJECTION_RISK", "strong", 31),
|
||||
_signal("TEXTBOOK_NAMING", "medium", 5),
|
||||
]
|
||||
)
|
||||
assert [group["code"] for group in groups] == ["INJECTION_RISK", "TEXTBOOK_NAMING"]
|
||||
assert groups[0]["count"] == 3
|
||||
assert groups[0]["lines"] == [10, 22, 31]
|
||||
assert groups[1]["count"] == 1
|
||||
|
||||
|
||||
def test_signal_groups_orders_by_severity_then_count():
|
||||
groups = _signal_groups(
|
||||
[_signal("WEAK_A", "weak")] * 5
|
||||
+ [_signal("MEDIUM_A", "medium")]
|
||||
+ [_signal("STRONG_A", "strong")]
|
||||
+ [_signal("WEAK_B", "weak")] * 2
|
||||
)
|
||||
assert [group["code"] for group in groups] == ["STRONG_A", "MEDIUM_A", "WEAK_A", "WEAK_B"]
|
||||
|
||||
|
||||
def test_signal_groups_tolerates_malformed_entries():
|
||||
groups = _signal_groups(["not-a-dict", {"code": "X", "severity": "strong", "line": "n/a"}])
|
||||
assert len(groups) == 1
|
||||
assert groups[0]["lines"] == []
|
||||
|
||||
|
||||
def test_linkify_sources_rewrites_every_reference_form():
|
||||
from devplacepy.routers.tools.isslop import _linkify_sources
|
||||
|
||||
markdown = (
|
||||
"The `src/libs/Env.ts:12` line in `src/libs/Env.ts` and bare src/libs/Env.ts plus "
|
||||
"src/libs/Env.ts:30 with signal AI_SIGNATURE and `unknown.ts`."
|
||||
)
|
||||
result = _linkify_sources(markdown, "uid1", {"src/libs/Env.ts"}, {"AI_SIGNATURE"})
|
||||
assert "[`src/libs/Env.ts:12`](/tools/isslop/uid1/source?path=src%2Flibs%2FEnv.ts&line=12#L12)" in result
|
||||
assert "[`src/libs/Env.ts`](/tools/isslop/uid1/source?path=src%2Flibs%2FEnv.ts)" in result
|
||||
assert "[src/libs/Env.ts:30](/tools/isslop/uid1/source?path=src%2Flibs%2FEnv.ts&line=30#L30)" in result
|
||||
assert "[AI_SIGNATURE](/docs/isslop-checks.html)" in result
|
||||
assert "`unknown.ts`" in result
|
||||
|
||||
|
||||
def test_linkify_sources_never_rewrites_inside_generated_links():
|
||||
from devplacepy.routers.tools.isslop import _linkify_sources
|
||||
|
||||
markdown = "`a/b.ts` then `a/b.ts` again"
|
||||
result = _linkify_sources(markdown, "uid1", {"a/b.ts"}, set())
|
||||
assert result.count("](/tools/isslop/uid1/source?path=a%2Fb.ts)") == 2
|
||||
assert "[[" not in result and "]](" not in result
|
||||
|
||||
|
||||
def test_source_url_encodes_path_and_line():
|
||||
from devplacepy.routers.tools.isslop import _source_url
|
||||
|
||||
assert _source_url("u1", "a b/c.ts") == "/tools/isslop/u1/source?path=a%20b%2Fc.ts"
|
||||
assert _source_url("u1", "x.ts", 12).endswith("&line=12#L12")
|
||||
334
tests/unit/services/containers.py.bak
Normal file
334
tests/unit/services/containers.py.bak
Normal file
@ -0,0 +1,334 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import timedelta
|
||||
import pytest
|
||||
import requests
|
||||
from tests.conftest import BASE_URL, run_async
|
||||
from devplacepy.database import db, get_table, init_db, refresh_snapshot
|
||||
from devplacepy.utils import generate_uid
|
||||
from devplacepy import config, project_files
|
||||
from devplacepy.services.containers import api, store, runtime
|
||||
from devplacepy.services.containers.backend.base import Mount, PortMapping, RunSpec
|
||||
from devplacepy.services.containers.backend.docker_cli import build_run_argv, parse_size
|
||||
from devplacepy.services.containers.backend.fake import FakeBackend
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
from devplacepy.services.containers.api import INSTANCE_LABEL
|
||||
from devplacepy.services.devii.tasks.schedule import Schedule, now_utc
|
||||
_CONTAINER_TABLES = (
|
||||
"instances",
|
||||
"instance_events",
|
||||
"instance_metrics",
|
||||
"instance_schedules",
|
||||
)
|
||||
@pytest.fixture(autouse=True)
|
||||
def _init_db_containers():
|
||||
init_db()
|
||||
yield
|
||||
@pytest.fixture
|
||||
def env(tmp_path, monkeypatch):
|
||||
fake = FakeBackend()
|
||||
runtime.set_backend(fake)
|
||||
monkeypatch.setattr("devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path / "ws")
|
||||
pid = "ctest-p1"
|
||||
project = {"uid": pid, "slug": "ctest", "title": "C", "user_uid": "ctest-u1"}
|
||||
user = {"uid": "ctest-u1", "username": "ctestadmin"}
|
||||
get_table("users").insert(
|
||||
{
|
||||
"uid": user["uid"],
|
||||
"username": user["username"],
|
||||
"email": "ctestadmin@example.com",
|
||||
"api_key": generate_uid(),
|
||||
"password_hash": "",
|
||||
"role": "Member",
|
||||
"is_active": True,
|
||||
"level": 1,
|
||||
"xp": 0,
|
||||
"stars": 0,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
refresh_snapshot()
|
||||
project_files.write_text_file(pid, user, "app.py", "print(1)\n")
|
||||
yield {"fake": fake, "project": project, "user": user}
|
||||
runtime.set_backend(None)
|
||||
get_table("users").delete(uid=user["uid"])
|
||||
for table in _CONTAINER_TABLES:
|
||||
if table in db.tables:
|
||||
for row in [r for r in get_table(table).find()]:
|
||||
if str(row.get("project_uid", "")).startswith("ctest"):
|
||||
get_table(table).delete(uid=row["uid"])
|
||||
for row in list(get_table("project_files").find()):
|
||||
if str(row.get("project_uid", "")).startswith("ctest"):
|
||||
get_table("project_files").delete(uid=row["uid"])
|
||||
def _ready_instance(env, **kwargs):
|
||||
return run_async(
|
||||
api.create_instance(env["project"], name=kwargs.pop("name", "inst"), **kwargs)
|
||||
)
|
||||
def _promote_admin(username: str) -> None:
|
||||
users = get_table("users")
|
||||
user = users.find_one(username=username)
|
||||
if user:
|
||||
users.update({"uid": user["uid"], "role": "Admin"}, ["uid"])
|
||||
def _api_key(username: str) -> str:
|
||||
refresh_snapshot()
|
||||
return get_table("users").find_one(username=username)["api_key"]
|
||||
|
||||
|
||||
def test_build_run_argv_exact():
|
||||
spec = RunSpec(
|
||||
image="ppy:latest",
|
||||
name="inst",
|
||||
labels={INSTANCE_LABEL: "u1"},
|
||||
env={"A": "B"},
|
||||
cpu_limit="1.5",
|
||||
mem_limit="512m",
|
||||
ports=[PortMapping(8080, 80)],
|
||||
mounts=[Mount("/ws", "/app")],
|
||||
restart_policy="on-failure",
|
||||
command=["python", "app.py"],
|
||||
)
|
||||
argv = build_run_argv(spec)
|
||||
assert argv[:5] == ["docker", "run", "-d", "--name", "inst"]
|
||||
assert "--label" in argv and f"{INSTANCE_LABEL}=u1" in argv
|
||||
assert "--cpus" in argv and "1.5" in argv
|
||||
assert "-p" in argv and "8080:80/tcp" in argv
|
||||
assert "-v" in argv and "/ws:/app:rw" in argv
|
||||
assert "--restart" in argv and "on-failure" in argv
|
||||
assert argv[-3:] == ["ppy:latest", "python", "app.py"]
|
||||
|
||||
|
||||
def test_never_policy_not_passed_to_docker():
|
||||
spec = RunSpec(image="ppy:latest", name="n", restart_policy="never")
|
||||
assert "--restart" not in build_run_argv(spec)
|
||||
|
||||
|
||||
def test_parse_size():
|
||||
assert parse_size("1.0GiB") == 1024**3
|
||||
assert parse_size("512MB") == 512 * 1024**2
|
||||
|
||||
|
||||
def test_create_instance_uses_shared_image(env):
|
||||
inst = run_async(
|
||||
api.create_instance(
|
||||
env["project"], name="inst", actor=("user", env["user"]["uid"])
|
||||
)
|
||||
)
|
||||
assert inst["name"] == "inst"
|
||||
assert inst["owner_uid"] == "ctest-u1"
|
||||
spec = api.run_spec_for(inst, config.CONTAINER_IMAGE)
|
||||
assert spec.image == config.CONTAINER_IMAGE
|
||||
assert any(m.container == "/app" for m in spec.mounts)
|
||||
|
||||
|
||||
def test_create_instance_requires_built_image(env):
|
||||
async def no_image(ref):
|
||||
return False
|
||||
|
||||
env["fake"].image_exists = no_image
|
||||
with pytest.raises(api.ContainerError):
|
||||
run_async(api.create_instance(env["project"], name="inst"))
|
||||
|
||||
|
||||
def test_reconcile_launches_and_stops(env):
|
||||
inst = _ready_instance(env, restart_policy="never", autostart=True)
|
||||
assert inst["desired_state"] == store.DESIRED_RUNNING
|
||||
service = ContainerService()
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
inst = store.get_instance(inst["uid"])
|
||||
assert inst["status"] == store.ST_RUNNING and inst["container_id"]
|
||||
assert [r.name for r in run_async(env["fake"].ps())] == [inst["slug"]]
|
||||
api.set_desired_state(inst, store.DESIRED_STOPPED)
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
assert store.get_instance(inst["uid"])["status"] == store.ST_STOPPED
|
||||
|
||||
|
||||
def test_manual_start_relaunches_after_never_policy_exit(env):
|
||||
fake = env["fake"]
|
||||
inst = _ready_instance(env, restart_policy="never", autostart=True)
|
||||
service = ContainerService()
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
inst = store.get_instance(inst["uid"])
|
||||
cid = inst["container_id"]
|
||||
assert inst["status"] == store.ST_RUNNING and cid
|
||||
|
||||
fake._set_state(cid, "exited", 0)
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
inst = store.get_instance(inst["uid"])
|
||||
assert inst["status"] == store.ST_STOPPED
|
||||
assert inst["desired_state"] == store.DESIRED_STOPPED
|
||||
|
||||
api.set_desired_state(inst, store.DESIRED_RUNNING)
|
||||
refresh_snapshot()
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
inst = store.get_instance(inst["uid"])
|
||||
assert inst["status"] == store.ST_RUNNING
|
||||
assert inst["desired_state"] == store.DESIRED_RUNNING
|
||||
assert cid in fake.removed
|
||||
assert inst["container_id"] and inst["container_id"] != cid
|
||||
|
||||
|
||||
def test_reconcile_reaps_orphan(env):
|
||||
fake = env["fake"]
|
||||
run_async(
|
||||
fake.run(
|
||||
RunSpec(
|
||||
image="ppy:latest", name="ghost", labels={INSTANCE_LABEL: "missing-uid"}
|
||||
)
|
||||
)
|
||||
)
|
||||
service = ContainerService()
|
||||
run_async(service.run_once())
|
||||
assert not run_async(fake.ps())
|
||||
assert fake.removed
|
||||
|
||||
|
||||
def test_reconcile_removes_marked_instance(env):
|
||||
inst = _ready_instance(env, autostart=True)
|
||||
service = ContainerService()
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
inst = store.get_instance(inst["uid"])
|
||||
api.mark_for_removal(inst)
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
assert store.get_instance(inst["uid"]) is None
|
||||
assert not run_async(env["fake"].ps())
|
||||
|
||||
|
||||
def test_schedule_fires(env):
|
||||
inst = _ready_instance(env, autostart=False)
|
||||
assert inst["desired_state"] == store.DESIRED_STOPPED
|
||||
past = Schedule(kind="once", run_at=now_utc() - timedelta(hours=1))
|
||||
api.add_schedule(inst, "start", past)
|
||||
service = ContainerService()
|
||||
run_async(service._fire_schedules())
|
||||
refresh_snapshot()
|
||||
assert store.get_instance(inst["uid"])["desired_state"] == store.DESIRED_RUNNING
|
||||
|
||||
|
||||
def test_ingress_validation(env):
|
||||
from devplacepy.services.containers.backend.base import PortMapping
|
||||
|
||||
assert api.validate_ingress("zwoeks", 8899, [PortMapping(8899, 8899)]) == (
|
||||
"zwoeks",
|
||||
8899,
|
||||
)
|
||||
assert api.validate_ingress("", None, []) == ("", 0)
|
||||
with pytest.raises(api.ContainerError):
|
||||
api.validate_ingress("BAD SLUG", None, [PortMapping(80, 80)])
|
||||
with pytest.raises(api.ContainerError):
|
||||
api.validate_ingress("x", 9999, [PortMapping(80, 80)])
|
||||
store.create_instance(
|
||||
{
|
||||
"uid": "z",
|
||||
"project_uid": "ctest-p1",
|
||||
"name": "z",
|
||||
"ports_json": "[]",
|
||||
"ingress_slug": "taken",
|
||||
}
|
||||
)
|
||||
with pytest.raises(api.ContainerError):
|
||||
api.validate_ingress("taken", None, [PortMapping(80, 80)])
|
||||
|
||||
|
||||
def test_validate_boot_languages():
|
||||
assert api.validate_boot("none", "ignored") == ("none", "")
|
||||
assert api.validate_boot("python", "print(1)") == ("python", "print(1)")
|
||||
with pytest.raises(api.ContainerError):
|
||||
api.validate_boot("ruby", "puts 1")
|
||||
with pytest.raises(api.ContainerError):
|
||||
api.validate_boot("bash", " ")
|
||||
|
||||
|
||||
def test_boot_script_precedence_in_run_spec(env):
|
||||
inst = _ready_instance(
|
||||
env,
|
||||
boot_language="python",
|
||||
boot_script="print('boot')\n",
|
||||
boot_command="python other.py",
|
||||
autostart=False,
|
||||
)
|
||||
spec = api.run_spec_for(inst, config.CONTAINER_IMAGE)
|
||||
assert spec.command == ["python", "/app/.devplace_boot.py"]
|
||||
inst2 = _ready_instance(
|
||||
env, name="i2", boot_command="python serve.py", autostart=False
|
||||
)
|
||||
spec2 = api.run_spec_for(inst2, config.CONTAINER_IMAGE)
|
||||
assert spec2.command == ["/bin/sh", "-c", "python serve.py"]
|
||||
|
||||
|
||||
def test_materialize_boot_script_writes_file(env, tmp_path):
|
||||
workspace = tmp_path / "boot-ws"
|
||||
inst = _ready_instance(
|
||||
env, name="boot", boot_language="bash", boot_script="echo hi\n", autostart=False
|
||||
)
|
||||
store.update_instance(inst["uid"], {"workspace_dir": str(workspace)})
|
||||
inst = store.get_instance(inst["uid"])
|
||||
api.materialize_boot_script(inst)
|
||||
written = workspace / ".devplace_boot.sh"
|
||||
assert written.is_file()
|
||||
assert written.read_text() == "echo hi\n"
|
||||
|
||||
|
||||
def test_run_as_uid_drives_pravda_env(env):
|
||||
_promote_admin(env["user"]["username"])
|
||||
key = _api_key(env["user"]["username"])
|
||||
inst = _ready_instance(
|
||||
env, name="runas", run_as_uid=env["user"]["uid"], autostart=False
|
||||
)
|
||||
pravda = api.pravda_env(inst)
|
||||
assert pravda["PRAVDA_API_KEY"] == key
|
||||
assert pravda["PRAVDA_USER_UID"] == env["user"]["uid"]
|
||||
|
||||
|
||||
def test_run_as_uid_rejects_unknown_user(env):
|
||||
with pytest.raises(api.ContainerError):
|
||||
run_async(
|
||||
api.create_instance(
|
||||
env["project"], name="bad", run_as_uid="nope-uid", autostart=False
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_start_on_boot_pass_forces_running(env):
|
||||
inst = _ready_instance(env, name="boot", start_on_boot=True, autostart=False)
|
||||
assert inst["desired_state"] == store.DESIRED_STOPPED
|
||||
service = ContainerService()
|
||||
service._boot_pass(store.all_instances())
|
||||
refresh_snapshot()
|
||||
assert store.get_instance(inst["uid"])["desired_state"] == store.DESIRED_RUNNING
|
||||
|
||||
|
||||
def test_status_change_records_event(env):
|
||||
inst = _ready_instance(env, name="hist", restart_policy="never", autostart=True)
|
||||
service = ContainerService()
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
inst = store.get_instance(inst["uid"])
|
||||
api.set_desired_state(inst, store.DESIRED_STOPPED)
|
||||
run_async(service.run_once())
|
||||
refresh_snapshot()
|
||||
events = [e["event"] for e in store.list_events(inst["uid"])]
|
||||
assert "status_change" in events
|
||||
|
||||
|
||||
def test_bidirectional_sync_newer_wins(env, tmp_path):
|
||||
workspace = tmp_path / "sync-ws"
|
||||
workspace.mkdir()
|
||||
pid = env["project"]["uid"]
|
||||
user = env["user"]
|
||||
project_files.write_text_file(pid, user, "shared.txt", "from project\n")
|
||||
fs_only = workspace / "fromfs.txt"
|
||||
fs_only.write_text("from fs\n")
|
||||
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||
assert counts["exported"] >= 1
|
||||
assert counts["imported"] >= 1
|
||||
assert (workspace / "shared.txt").read_text() == "from project\n"
|
||||
imported = project_files.read_file(pid, "fromfs.txt")
|
||||
assert imported["content"] == "from fs\n"
|
||||
0
tests/unit/services/jobs/isslop/__init__.py
Normal file
0
tests/unit/services/jobs/isslop/__init__.py
Normal file
28
tests/unit/services/jobs/isslop/badge.py
Normal file
28
tests/unit/services/jobs/isslop/badge.py
Normal file
@ -0,0 +1,28 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.jobs.isslop.badge import badge_html, badge_markdown, render_badge
|
||||
|
||||
|
||||
def test_render_badge_with_grade():
|
||||
svg = render_badge(87.5, "A", "https://devplace.example/tools/isslop/abc/report")
|
||||
assert svg.startswith("<svg")
|
||||
assert "88%" in svg or "87%" in svg
|
||||
assert "authenticity" in svg
|
||||
assert "https://devplace.example/tools/isslop/abc/report" in svg
|
||||
|
||||
|
||||
def test_render_badge_pending():
|
||||
svg = render_badge(None, None, "https://devplace.example/tools/isslop/abc/report")
|
||||
assert "analyzing" in svg
|
||||
|
||||
|
||||
def test_render_badge_escapes_url():
|
||||
svg = render_badge(50.0, "C", 'https://x.example/"><script>')
|
||||
assert "<script>" not in svg
|
||||
|
||||
|
||||
def test_badge_snippets():
|
||||
markdown = badge_markdown("https://x/badge.svg", "https://x/report")
|
||||
assert markdown == "[](https://x/report)"
|
||||
html = badge_html("https://x/badge.svg", "https://x/report")
|
||||
assert '<img src="https://x/badge.svg"' in html
|
||||
31
tests/unit/services/jobs/isslop/events.py
Normal file
31
tests/unit/services/jobs/isslop/events.py
Normal file
@ -0,0 +1,31 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
from devplacepy.services.jobs.isslop.events import KIND_DONE, WorkerEvent
|
||||
|
||||
|
||||
def test_event_roundtrip():
|
||||
event = WorkerEvent(kind=KIND_DONE, message="Analysis complete", data={"grade": "A"})
|
||||
parsed = WorkerEvent.parse(event.to_json())
|
||||
assert parsed is not None
|
||||
assert parsed.kind == KIND_DONE
|
||||
assert parsed.message == "Analysis complete"
|
||||
assert parsed.data == {"grade": "A"}
|
||||
|
||||
|
||||
def test_parse_rejects_non_json_lines():
|
||||
assert WorkerEvent.parse("") is None
|
||||
assert WorkerEvent.parse("plain log output") is None
|
||||
assert WorkerEvent.parse("{broken json") is None
|
||||
|
||||
|
||||
def test_parse_rejects_missing_fields():
|
||||
assert WorkerEvent.parse(json.dumps({"kind": "log"})) is None
|
||||
assert WorkerEvent.parse(json.dumps({"message": "no kind"})) is None
|
||||
|
||||
|
||||
def test_parse_tolerates_non_dict_data():
|
||||
parsed = WorkerEvent.parse(json.dumps({"kind": "log", "message": "x", "data": [1, 2]}))
|
||||
assert parsed is not None
|
||||
assert parsed.data == {}
|
||||
87
tests/unit/services/jobs/isslop/hallucination.py
Normal file
87
tests/unit/services/jobs/isslop/hallucination.py
Normal file
@ -0,0 +1,87 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.engine import _javascript_alias_prefixes
|
||||
from devplacepy.services.jobs.isslop.analysis.signals import FileContext, RepoContext
|
||||
from devplacepy.services.jobs.isslop.analysis.signals.hallucination import detect_hallucination
|
||||
|
||||
|
||||
def _context(text: str, aliases: frozenset = frozenset(), deps: frozenset = frozenset({"next", "react", "@clerk/nextjs"})):
|
||||
repo = RepoContext(
|
||||
root=Path("/tmp"),
|
||||
python_dependencies=frozenset(),
|
||||
javascript_dependencies=deps,
|
||||
local_python_modules=frozenset(),
|
||||
has_python_manifest=False,
|
||||
has_javascript_manifest=True,
|
||||
javascript_alias_prefixes=aliases,
|
||||
)
|
||||
return FileContext(
|
||||
path=Path("/tmp/x.tsx"),
|
||||
relative="src/x.tsx",
|
||||
language="typescript",
|
||||
text=text,
|
||||
lines=text.splitlines(),
|
||||
comments=[],
|
||||
metrics=None,
|
||||
repo=repo,
|
||||
fingerprint_only=False,
|
||||
)
|
||||
|
||||
|
||||
def test_path_aliases_are_never_unresolved():
|
||||
text = (
|
||||
"import { Sponsors } from '@/components/Sponsors';\n"
|
||||
"import config from '~/config';\n"
|
||||
"import db from '#app/db';\n"
|
||||
"import { page } from '$app/stores';\n"
|
||||
)
|
||||
assert detect_hallucination(_context(text)) == []
|
||||
|
||||
|
||||
def test_tsconfig_alias_prefixes_are_respected():
|
||||
text = "import { helper } from 'src/utils/helper';\n"
|
||||
assert len(detect_hallucination(_context(text))) == 1
|
||||
assert detect_hallucination(_context(text, aliases=frozenset({"src/"}))) == []
|
||||
|
||||
|
||||
def test_declared_package_subpaths_resolve():
|
||||
text = (
|
||||
"import Image from 'next/image';\n"
|
||||
"import { auth } from '@clerk/nextjs/server';\n"
|
||||
"import { useState } from 'react';\n"
|
||||
)
|
||||
assert detect_hallucination(_context(text)) == []
|
||||
|
||||
|
||||
def test_node_builtins_and_schemes_resolve():
|
||||
text = (
|
||||
"import fs from 'node:fs';\n"
|
||||
"import hooks from 'async_hooks';\n"
|
||||
"import content from 'virtual:generated';\n"
|
||||
)
|
||||
assert detect_hallucination(_context(text)) == []
|
||||
|
||||
|
||||
def test_genuinely_missing_package_is_flagged():
|
||||
findings = detect_hallucination(_context("import magic from 'left-pad-ultra';\n"))
|
||||
assert len(findings) == 1
|
||||
assert findings[0].code == "DEP_UNRESOLVED"
|
||||
assert "left-pad-ultra" in findings[0].title
|
||||
|
||||
|
||||
def test_alias_prefix_parser_reads_jsonc(tmp_path):
|
||||
(tmp_path / "tsconfig.json").write_text(
|
||||
"""{
|
||||
// path aliases
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"~lib/*": ["./lib/*"], /* legacy */
|
||||
},
|
||||
},
|
||||
}""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert _javascript_alias_prefixes(tmp_path) == frozenset({"@/", "~lib/"})
|
||||
69
tests/unit/services/jobs/isslop/pipeline.py
Normal file
69
tests/unit/services/jobs/isslop/pipeline.py
Normal file
@ -0,0 +1,69 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.jobs.isslop.agent.classifier import AiVerdict
|
||||
from devplacepy.services.jobs.isslop.agent.reporter import _summary_payload, fallback_report
|
||||
from devplacepy.services.jobs.isslop.analysis.templates import TemplateEvidence
|
||||
from devplacepy.services.jobs.isslop.analysis.scoring import FileScore, aggregate
|
||||
from devplacepy.services.jobs.isslop.pipeline import apply_ai_verdicts
|
||||
|
||||
|
||||
def _file(relative, sloc, origin, quality=10.0):
|
||||
return FileScore(
|
||||
relative=relative,
|
||||
language="python",
|
||||
sloc=sloc,
|
||||
origin_score=origin,
|
||||
quality_deficit=quality,
|
||||
category="human-clean",
|
||||
criticality=1.0,
|
||||
signals=[],
|
||||
)
|
||||
|
||||
|
||||
def _verdict(path, probability):
|
||||
return AiVerdict(
|
||||
path=path,
|
||||
origin_score=probability,
|
||||
quality_deficit=10.0,
|
||||
ai_probability=probability,
|
||||
category="uncertain",
|
||||
reasoning="",
|
||||
notable_signals=[],
|
||||
)
|
||||
|
||||
|
||||
def test_apply_ai_verdicts_only_touches_reviewed_files():
|
||||
scores = [_file("a.py", 500, 20.0), _file("b.py", 500, 20.0)]
|
||||
adjusted = apply_ai_verdicts(scores, [_verdict("a.py", 80.0)])
|
||||
assert adjusted[0].origin_score == round(0.6 * 20.0 + 0.4 * 80.0, 1)
|
||||
assert adjusted[1].origin_score == 20.0
|
||||
|
||||
|
||||
def test_hedging_verdicts_cannot_dominate_a_large_repo():
|
||||
scores = [_file(f"f{i}.py", 200, 15.0) for i in range(100)]
|
||||
verdicts = [_verdict(f"f{i}.py", 40.0) for i in range(12)]
|
||||
static = aggregate(scores)
|
||||
final = aggregate(apply_ai_verdicts(scores, verdicts))
|
||||
assert static.ai_percent == 0.0
|
||||
assert final.ai_percent < 5.0
|
||||
assert final.human_percent > 95.0
|
||||
|
||||
|
||||
def test_strong_verdict_on_heavy_file_moves_the_verdict():
|
||||
scores = [_file("huge.py", 5000, 30.0)] + [_file(f"s{i}.py", 50, 15.0) for i in range(10)]
|
||||
final = aggregate(apply_ai_verdicts(scores, [_verdict("huge.py", 95.0)]))
|
||||
static = aggregate(scores)
|
||||
assert final.ai_percent > static.ai_percent + 30.0
|
||||
|
||||
|
||||
def test_report_payload_grade_matches_final_scores():
|
||||
scores = [_file("a.py", 300, 60.0, 40.0), _file("b.py", 200, 20.0)]
|
||||
verdicts = [_verdict("a.py", 90.0)]
|
||||
adjusted = apply_ai_verdicts(scores, verdicts)
|
||||
final = aggregate(adjusted)
|
||||
payload = _summary_payload("https://x.example/repo", "git", final, adjusted, verdicts, 0, [], {}, TemplateEvidence())
|
||||
assert payload["repo_scores"]["grade"] == final.grade
|
||||
assert payload["repo_scores"]["human_percent"] == final.human_percent
|
||||
markdown = fallback_report(payload)
|
||||
assert f"grade **{final.grade}**" in markdown
|
||||
assert f"**{final.human_percent}% human**" in markdown
|
||||
83
tests/unit/services/jobs/isslop/scoring.py
Normal file
83
tests/unit/services/jobs/isslop/scoring.py
Normal file
@ -0,0 +1,83 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.scoring import (
|
||||
CATEGORY_HUMAN_CLEAN,
|
||||
CATEGORY_HUMAN_MESSY,
|
||||
CATEGORY_SLOP,
|
||||
CATEGORY_SOPHISTICATED,
|
||||
CATEGORY_UNCERTAIN,
|
||||
categorize,
|
||||
compose_slop_score,
|
||||
grade_for,
|
||||
)
|
||||
|
||||
|
||||
def test_compose_slop_score_bounds():
|
||||
assert compose_slop_score(0.0, 0.0) == 0.0
|
||||
assert compose_slop_score(100.0, 100.0) == 100.0
|
||||
assert 0.0 <= compose_slop_score(50.0, 50.0) <= 100.0
|
||||
|
||||
|
||||
def test_compose_slop_score_weighs_ai_heavier_than_quality():
|
||||
ai_heavy = compose_slop_score(80.0, 20.0)
|
||||
quality_heavy = compose_slop_score(20.0, 80.0)
|
||||
assert ai_heavy > quality_heavy
|
||||
|
||||
|
||||
def test_grade_for_is_monotonic():
|
||||
grades = [grade_for(value) for value in (0.0, 20.0, 40.0, 60.0, 80.0, 100.0)]
|
||||
order = "ABCDF"
|
||||
positions = [order.index(grade) for grade in grades]
|
||||
assert positions == sorted(positions)
|
||||
assert grades[0] == "A"
|
||||
assert grades[-1] == "F"
|
||||
|
||||
|
||||
def test_categorize_corners():
|
||||
assert categorize(95.0, 95.0) == CATEGORY_SLOP
|
||||
assert categorize(95.0, 5.0) == CATEGORY_SOPHISTICATED
|
||||
assert categorize(5.0, 5.0) == CATEGORY_HUMAN_CLEAN
|
||||
assert categorize(5.0, 95.0) == CATEGORY_HUMAN_MESSY
|
||||
assert categorize(50.0, 50.0) == CATEGORY_UNCERTAIN
|
||||
|
||||
|
||||
def test_ai_fraction_is_continuous_and_monotonic():
|
||||
from devplacepy.services.jobs.isslop.analysis.scoring import ai_fraction
|
||||
|
||||
assert ai_fraction(0.0) == 0.0
|
||||
assert ai_fraction(35.0) == 0.0
|
||||
assert ai_fraction(50.0) == 0.5
|
||||
assert ai_fraction(65.0) == 1.0
|
||||
assert ai_fraction(100.0) == 1.0
|
||||
samples = [ai_fraction(v) for v in range(30, 71)]
|
||||
assert samples == sorted(samples)
|
||||
steps = [b - a for a, b in zip(samples, samples[1:])]
|
||||
assert max(steps) < 0.06
|
||||
|
||||
|
||||
def test_adjust_for_images_recomputes_grade_consistently():
|
||||
from devplacepy.services.jobs.isslop.analysis.scoring import (
|
||||
RepoScores,
|
||||
adjust_for_images,
|
||||
compose_slop_score,
|
||||
grade_for,
|
||||
)
|
||||
|
||||
scores = RepoScores(
|
||||
origin_score=10.0,
|
||||
quality_deficit=10.0,
|
||||
slop_score=compose_slop_score(2.0, 10.0),
|
||||
grade=grade_for(compose_slop_score(2.0, 10.0)),
|
||||
category="human-clean",
|
||||
human_percent=98.0,
|
||||
ai_percent=2.0,
|
||||
confidence="medium",
|
||||
strong_signal_count=0,
|
||||
medium_signal_count=0,
|
||||
files_scored=50,
|
||||
)
|
||||
adjusted = adjust_for_images(scores, 90.0)
|
||||
assert adjusted.ai_percent == round(0.75 * 2.0 + 0.25 * 90.0, 1)
|
||||
assert adjusted.human_percent == round(100.0 - adjusted.ai_percent, 1)
|
||||
assert adjusted.slop_score == compose_slop_score(adjusted.ai_percent, scores.quality_deficit)
|
||||
assert adjusted.grade == grade_for(adjusted.slop_score)
|
||||
79
tests/unit/services/jobs/isslop/templates.py
Normal file
79
tests/unit/services/jobs/isslop/templates.py
Normal file
@ -0,0 +1,79 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
from devplacepy.services.jobs.isslop.analysis.scoring import RepoScores, adjust_for_template, aggregate
|
||||
from devplacepy.services.jobs.isslop.analysis.templates import detect_template
|
||||
|
||||
|
||||
def _scores(ai=2.0, quality=20.0):
|
||||
return RepoScores(
|
||||
origin_score=20.0,
|
||||
quality_deficit=quality,
|
||||
slop_score=7.8,
|
||||
grade="A",
|
||||
category="human-clean",
|
||||
human_percent=100.0 - ai,
|
||||
ai_percent=ai,
|
||||
confidence="medium",
|
||||
strong_signal_count=0,
|
||||
medium_signal_count=0,
|
||||
files_scored=50,
|
||||
)
|
||||
|
||||
|
||||
def test_untouched_boilerplate_is_detected(tmp_path):
|
||||
(tmp_path / "package.json").write_text(json.dumps({
|
||||
"name": "next-js-boilerplate",
|
||||
"author": "Ixartz (https://github.com/ixartz)",
|
||||
}))
|
||||
(tmp_path / "README.md").write_text(
|
||||
"# Boilerplate and Starter for Next.js\n\nSponsors welcome. Demo at https://demo.nextjs-boilerplate.com\n"
|
||||
)
|
||||
for artifact in (".storybook", ".husky", ".devcontainer"):
|
||||
(tmp_path / artifact).mkdir()
|
||||
for artifact in ("commitlint.config.ts", "playwright.config.ts", "vitest.config.ts", "drizzle.config.ts", "crowdin.yml", "CHANGELOG.md"):
|
||||
(tmp_path / artifact).write_text("")
|
||||
evidence = detect_template(tmp_path)
|
||||
assert evidence.score >= 70.0
|
||||
assert any("known template" in marker for marker in evidence.markers)
|
||||
adjusted = adjust_for_template(_scores(), evidence.score)
|
||||
assert adjusted.grade in ("C", "D", "F")
|
||||
assert adjusted.ai_percent >= 55.0
|
||||
assert adjusted.category == "ai-slop"
|
||||
|
||||
|
||||
def test_t3_scaffold_metadata_is_detected(tmp_path):
|
||||
(tmp_path / "package.json").write_text(json.dumps({
|
||||
"name": "my-cool-product",
|
||||
"ct3aMetadata": {"initVersion": "7.39.0"},
|
||||
}))
|
||||
evidence = detect_template(tmp_path)
|
||||
assert any("create-t3-app" in marker for marker in evidence.markers)
|
||||
|
||||
|
||||
def test_genuine_project_produces_no_evidence(tmp_path):
|
||||
(tmp_path / "package.json").write_text(json.dumps({
|
||||
"name": "acme-billing-portal",
|
||||
"description": "Internal billing portal for Acme",
|
||||
}))
|
||||
(tmp_path / "README.md").write_text("# Acme billing portal\n\nInternal tool, see the wiki for onboarding.\n")
|
||||
(tmp_path / "Dockerfile").write_text("")
|
||||
evidence = detect_template(tmp_path)
|
||||
assert evidence.score < 35.0
|
||||
assert adjust_for_template(_scores(), evidence.score) == _scores()
|
||||
|
||||
|
||||
def test_weak_evidence_below_gate_never_adjusts():
|
||||
base = _scores()
|
||||
assert adjust_for_template(base, 34.9) == base
|
||||
adjusted = adjust_for_template(base, 90.0)
|
||||
assert adjusted.ai_percent == round(0.85 * 90.0, 1)
|
||||
assert adjusted.human_percent == round(100.0 - adjusted.ai_percent, 1)
|
||||
assert adjusted.category == "ai-slop"
|
||||
|
||||
|
||||
def test_confident_but_partial_evidence_caps_category_at_uncertain():
|
||||
adjusted = adjust_for_template(_scores(), 55.0)
|
||||
assert adjusted.category == "uncertain"
|
||||
assert adjusted.grade != "A"
|
||||
33
tests/unit/services/jobs/isslop/vision.py
Normal file
33
tests/unit/services/jobs/isslop/vision.py
Normal file
@ -0,0 +1,33 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from devplacepy.routers.tools.isslop import MEDIA_NAME_PATTERN
|
||||
from devplacepy.services.jobs.isslop.agent.vision import make_thumbnail
|
||||
|
||||
|
||||
def test_make_thumbnail_preserves_aspect_ratio(tmp_path):
|
||||
source = tmp_path / "wide.png"
|
||||
Image.new("RGB", (1600, 400), (255, 0, 0)).save(source)
|
||||
name = make_thumbnail(source, tmp_path / "media", "assets/wide.png")
|
||||
assert name is not None
|
||||
assert MEDIA_NAME_PATTERN.match(name)
|
||||
with Image.open(tmp_path / "media" / name) as thumb:
|
||||
assert thumb.format == "WEBP"
|
||||
assert thumb.width <= 480 and thumb.height <= 480
|
||||
assert abs(thumb.width / thumb.height - 4.0) < 0.05
|
||||
|
||||
|
||||
def test_make_thumbnail_is_deterministic_per_path(tmp_path):
|
||||
source = tmp_path / "icon.png"
|
||||
Image.new("RGBA", (256, 256), (0, 0, 255, 128)).save(source)
|
||||
first = make_thumbnail(source, tmp_path / "media", "public/icon.png")
|
||||
second = make_thumbnail(source, tmp_path / "media", "public/icon.png")
|
||||
assert first == second
|
||||
assert make_thumbnail(source, tmp_path / "media", "other/icon.png") != first
|
||||
|
||||
|
||||
def test_make_thumbnail_fails_soft_on_unreadable(tmp_path):
|
||||
source = tmp_path / "broken.png"
|
||||
source.write_bytes(b"not an image")
|
||||
assert make_thumbnail(source, tmp_path / "media", "broken.png") is None
|
||||
36
tests/unit/services/jobs/isslop/workspace.py
Normal file
36
tests/unit/services/jobs/isslop/workspace.py
Normal file
@ -0,0 +1,36 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.services.jobs.isslop.acquisition.workspace import (
|
||||
safe_relative_path,
|
||||
slugify,
|
||||
workspace_for,
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_for_stays_inside_root(tmp_path):
|
||||
workspace = workspace_for(tmp_path, "https://github.com/owner/repo", "abc123")
|
||||
assert workspace.parent == tmp_path.resolve()
|
||||
assert "github.com-owner-repo" in workspace.name
|
||||
assert workspace.name.endswith("abc123")
|
||||
|
||||
|
||||
def test_workspace_for_rejects_escaping_uid(tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
workspace_for(tmp_path, "https://example.com", "../../../outside/x")
|
||||
|
||||
|
||||
def test_safe_relative_path_rejects_traversal(tmp_path):
|
||||
resolved = safe_relative_path(tmp_path, "../../../etc/passwd")
|
||||
assert resolved.is_relative_to(tmp_path.resolve())
|
||||
|
||||
|
||||
def test_safe_relative_path_keeps_nested_files(tmp_path):
|
||||
resolved = safe_relative_path(tmp_path, "assets/js/app.js")
|
||||
assert resolved == tmp_path.resolve() / "assets" / "js" / "app.js"
|
||||
|
||||
|
||||
def test_slugify_strips_scheme_and_specials():
|
||||
assert slugify("https://Example.com/Owner/Repo.git") == "example.com-owner-repo.git"
|
||||
assert slugify("!!!") == "source"
|
||||
Loading…
Reference in New Issue
Block a user