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

This commit is contained in:
retoor 2026-07-06 03:58:46 +00:00
parent 9a8046ab2a
commit 499f91e16a
88 changed files with 21337 additions and 0 deletions

View 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"}]},
),
],
}

View 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"},
)

View 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],
}

View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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"),
),
),
)

View File

@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>

View File

@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>

View 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")

View 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

View 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"

View 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)

View 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"

View 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()

View File

@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>

View 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

View 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

View 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"

View 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,
}

View File

@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>

View 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 []

View File

@ -0,0 +1,2 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations

View File

@ -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

View File

@ -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,
)

View 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

View 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

View File

@ -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

View 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

View 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

View 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

View File

@ -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 []

View File

@ -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

View File

@ -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

View 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)

View 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

View 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

View 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,
)

View 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,
}

View 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

View File

@ -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

View 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

View File

@ -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 []

View File

@ -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

View 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

View 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]

View 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

View 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 []

View 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]

View 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

View 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

View 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]

View 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]

View 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)

View 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"[![authenticity human score]({badge_url})]({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>'
)

View 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)),
)

View 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 {})

View 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(),
}

View 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)

View 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)

View 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)

View 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()

View 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;
}

View 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);

View 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);

View 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/&lt;slug&gt;
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>

View 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>

View 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>

View 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>

View 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">&#x1F9F0;</span>
All tools
</a>
<a href="/tools/isslop" class="sidebar-link active">
<span class="icon">&#x1F9EA;</span>
AI Usage Analyzer
</a>
<a href="/docs/isslop-checks.html" class="sidebar-link">
<span class="icon">&#x1F4D6;</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 %}

View 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">&#x1F9F0;</span>
All tools
</a>
<a href="/tools/isslop" class="sidebar-link">
<span class="icon">&#x1F9EA;</span>
New analysis
</a>
<a href="/docs/tools-isslop.html" class="sidebar-link">
<span class="icon">&#x1F4D8;</span>
How it works
</a>
<a href="/docs/isslop-checks.html" class="sidebar-link">
<span class="icon">&#x1F4D6;</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 %}

View 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">&#x1F4C4;</span>
Back to report
</a>
<a href="/tools/isslop" class="sidebar-link">
<span class="icon">&#x1F9EA;</span>
New analysis
</a>
<a href="/docs/isslop-checks.html" class="sidebar-link">
<span class="icon">&#x1F4D6;</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
View 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
View 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
View 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 &amp; 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

View File

View 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")

View 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"

View 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 == "[![authenticity human score](https://x/badge.svg)](https://x/report)"
html = badge_html("https://x/badge.svg", "https://x/report")
assert '<img src="https://x/badge.svg"' in html

View 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 == {}

View 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/"})

View 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

View 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)

View 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"

View 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

View 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"