ai-lsc/src/ai_lsc/manifest/support.py

162 lines
5.5 KiB
Python
Executable File

"""
AI-LSC — Manifest support.
Reads and writes ``.ai-lsc-project.json`` and ``.ai-lsc-jobs.json``
files that provide project-level context for the chat interface.
Pure filesystem + JSON work — no UI.
Manifest schema (``.ai-lsc-project.json``)::
{
"project": "my-project",
"description": "Brief description for AI context",
"language": "python",
"entry_point": "src/main.py",
"architecture": "System architecture notes",
"environment_notes": "Runtime environment details",
"dependencies": ["package1", "package2"],
"context_files": ["src/**/*.py", "README.md"],
"exclude": ["__pycache__", "*.pyc", ".git"]
}
JCL schema (``.ai-lsc-jobs.json``)::
{
"jobs": [
{"name": "...", "command": "...", "cwd": "..."},
...
]
}
"""
from __future__ import annotations
import glob as glob_mod
import json
from pathlib import Path
from typing import Any
from ai_lsc.constants import MANIFEST_FILE_NAME, JCL_FILE_NAME
# Maximum directory traversal depth when searching for manifests.
_MAX_WALK_DEPTH: int = 20
class ManifestSupport:
"""Static utility class for manifest and JCL file operations."""
@staticmethod
def discover_manifest(directory: str | Path) -> Path | None:
"""Walk up from *directory* to find the nearest manifest file.
Stops after ``_MAX_WALK_DEPTH`` iterations or when the
filesystem root is reached.
"""
current = Path(directory).resolve()
for _ in range(_MAX_WALK_DEPTH):
candidate = current / MANIFEST_FILE_NAME
if candidate.exists():
return candidate
parent = current.parent
if parent == current:
return None
current = parent
return None
@staticmethod
def load_manifest(path: str | Path) -> dict[str, Any]:
"""Load and return the manifest dict, or ``{}`` on failure."""
p = Path(path)
if not p.exists():
return {}
try:
return json.loads(p.read_text(encoding="utf-8"))
except (OSError, ValueError, json.JSONDecodeError):
return {}
@staticmethod
def build_system_context(manifest: dict[str, Any]) -> str:
"""Build a flat system-prompt text block from manifest data.
Source-of-truth boundary: this method renders a *derived* view of
the manifest dict. The manifest file itself is an optional
convenience; if absent, callers fall back to defaults — the
registry layer files in :mod:`ai_lsc.registry.layers` remain
the authoritative source for tool definitions.
"""
# (manifest_key, label) pairs in display order. Each entry whose
# manifest value is truthy becomes one line in the prompt.
_FIELDS: tuple[tuple[str, str], ...] = (
("description", "Description"),
("language", "Language"),
("entry_point", "Entry Point"),
("architecture", "Architecture"),
("environment_notes", "Environment"),
)
project = manifest.get("project", "Unknown Project")
dependencies = manifest.get("dependencies", [])
parts = [f"Project: {project}"]
parts.extend(
f"{label}: {manifest.get(key)}"
for key, label in _FIELDS
if manifest.get(key)
)
if dependencies:
parts.append(f"Dependencies: {', '.join(dependencies)}")
return "\n".join(parts)
@staticmethod
def resolve_context_files(
manifest: dict[str, Any],
base_dir: str | Path,
) -> list[str]:
"""Resolve glob patterns in the manifest to real file paths."""
base = Path(base_dir)
patterns = manifest.get("context_files", [])
exclude = set(manifest.get("exclude", []))
# Single-pass comprehension: expand every pattern, keep only
# regular files whose path does not contain any excluded token.
return [
f
for pattern in patterns
for f in glob_mod.glob(str(base / pattern), recursive=True)
if Path(f).is_file() and not any(ex in f for ex in exclude)
]
@staticmethod
def load_jcl(path: str | Path) -> list[dict[str, Any]]:
"""Load job entries from a JCL file, or ``[]`` on failure."""
p = Path(path)
if not p.exists():
return []
try:
data = json.loads(p.read_text(encoding="utf-8"))
return data.get("jobs", [])
except (OSError, ValueError, json.JSONDecodeError):
return []
@staticmethod
def create_manifest_template(path: str | Path) -> Path:
"""Write a starter manifest template to *path*.
Returns the path of the created file.
"""
p = Path(path)
template = {
"project": "my-project",
"description": "Brief project description for AI context",
"language": "python",
"entry_point": "src/main.py",
"architecture": "Describe the system architecture",
"environment_notes": "Runtime environment details",
"dependencies": ["package1", "package2"],
"context_files": ["src/**/*.py", "README.md"],
"exclude": ["__pycache__", "*.pyc", ".git", "node_modules"],
}
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(template, indent=4), encoding="utf-8")
return p