diff --git a/.agents/skills/manage-requirements/SKILL.md b/.agents/skills/manage-requirements/SKILL.md new file mode 100644 index 0000000000..e02c9500f1 --- /dev/null +++ b/.agents/skills/manage-requirements/SKILL.md @@ -0,0 +1,134 @@ +--- +name: manage-requirements +description: >- + Author and validate FR/NFR requirement catalogs (MD + YAML) for any TCE + project. Use when creating, migrating, editing, or validating requirements, + specs with acceptance criteria, requirements.yaml, FR-/NFR- IDs, or + component/screen/script requirement catalogs. +--- + +# Manage requirements + +Cross-project skill. Schemas live here and apply to CollegeV2, Military, High School, Shared, or any new module. + +## File contract + +Each feature is a folder with exactly two artifacts: + +| File | Role | +| --- | --- | +| `.md` (or `README.md`) | Human/AI narrative: purpose, actors, config, links | +| `requirements.yaml` | Machine-checked catalog (`requirements[]`) | + +Schemas (source of truth): + +- [schemas/requirement.schema.json](schemas/requirement.schema.json) — single Requirement +- [schemas/requirements-catalog.schema.json](schemas/requirements-catalog.schema.json) — catalog document + +Templates: [templates/](templates/). + +## Catalog shape + +```yaml +specId: my-feature +kind: component # component | screen | script +title: My feature +config: {} # props / ports / section ids — keep FRs reusable +dependsOn: [] # other specId values +requirements: + - id: FR-001 + requirementType: Functional + title: ... + statement: ... + priority: High + status: Draft + acceptanceCriteria: + - given: ... + when: ... + then: ... +``` + +Rules: + +- Local IDs `FR-NNN` / `NFR-NNN` unique **within** one catalog +- Cross-feature refs: `specId#FR-001` in `dependencies` +- Functional → `acceptanceCriteria` required +- NonFunctional → `category`, `metrics`, `verificationMethod` required +- Edge-case scenario IDs (e.g. `CE-001`) go in `traceability.testCases` only + +## Authoring by kind + +1. **component** — Behavior given `config`. No screen names in `statement` / `acceptanceCriteria`. Screens list the component in `dependsOn` and describe their config in the MD. +2. **script** — Domain outcomes via named ports. No vendor URLs or ProcessMaker HTTP in statements; adapters belong in `config` / `assumptions`. +3. **screen** — Workflow composition, permissions, Continue rules. Reuse components via `dependsOn`; do not duplicate component FRs. + +### CollegeV2 course comments (`comments_log`) + +- Prefer a **component** catalog for shared `CommentsLogTable` (read-only thread); screen RFs `dependsOn` it and describe when the section is visible (`comments_log.length > 0`). +- In screen MD **assumptions**, state whether comments are stored in **case JSON** (`course.comments_log[]`), appended only on **save** from a draft field (`comments_note` / student `comments`), and/or loaded later via **`acol_applicant_course_comments`** HTTP — avoid contradictory `then` clauses across catalogs. +- Acceptance criteria should spell out **role** (`tco`, `student`), **read vs write**, and **append-on-save** (draft is not in the log until save succeeds). +- Multi-external articulation: if one save updates linked courses, say whether companions receive the same `comments_log` entries or only articulation/inbox fields. + +Suggested project layout (optional; any tree of `requirements.yaml` validates): + +```text +ai-docs//requirements/ + components//{.md,requirements.yaml} + screens//{.md,requirements.yaml} + scripts//{.md,requirements.yaml} +``` + +## Validate + +Primary gate (schema + heuristic quality): + +```bash +npm run check:requirements # all ai-docs/*/requirements +npm run check:requirements -- college-v2 # one project +npm run check:requirements -- military +``` + +Schema-only (path optional; default is every `ai-docs/*/requirements` tree): + +```bash +npm run validate:requirements +npm run validate:requirements -- path/to/requirements.yaml +npm run validate:requirements -- path/to/feature-or-tree +``` + +Schema checks required fields, unique IDs, `dependsOn`, companion markdown (`.md` or `README.md`), and non-empty Given/When/Then. Tautological acceptance criteria (placeholder given/when, or `then` repeating the title) are warnings, not hard failures. + +Quality harness (any project) lives in [quality/](quality/). Project text fixtures stay under `ai-docs//quality/` (`*.yaml` questions, optional `vocabulary.md`). `check:requirements` / `judge_catalog.py` exits non-zero while any FR fails (for example stub acceptance criteria). + +Harness unit tests (tooling, not catalog gates): + +```bash +npm run test:requirements-schema +npm run test:requirements-quality +``` + +Laya (downloads a local checkpoint): + +```bash +# pip install -r .agents/skills/manage-requirements/quality/requirements.txt +# REQUIREMENTS_PROJECT=college-v2 LAYA_SOURCE=laya .agents/skills/manage-requirements/quality/run_laya_eval.sh +``` + +Global tests are `ai-docs//quality/*.yaml` with `applies.all`. Domain extras are more YAML in that folder with `applies.ids` (or kinds/tags). + +Checklist before finishing: + +- [ ] MD + `requirements.yaml` pair exists +- [ ] `npm run check:requirements -- ` passes (or `validate:requirements -- ` for schema-only) +- [ ] IDs unique in catalog; Functional/NonFunctional fields complete +- [ ] Component/script statements stay screen-/vendor-agnostic +- [ ] Screen catalogs `dependsOn` shared components instead of copying FRs +- [ ] Edge cases linked via `traceability.testCases` when known + +## Create / migrate workflow + +1. Choose `kind` and `specId` (stable folder basename). +2. Copy templates from `templates/`; rename the MD to match `specId`. +3. Extract FRs from existing design docs / edge cases; map scenarios to acceptance criteria and `testCases`. +4. For screens that embed a reusable UI, create/reuse a **component** catalog first, then `dependsOn` it. +5. Validate; fix schema errors before committing. diff --git a/.agents/skills/manage-requirements/quality/README.md b/.agents/skills/manage-requirements/quality/README.md new file mode 100644 index 0000000000..413ab2b3d0 --- /dev/null +++ b/.agents/skills/manage-requirements/quality/README.md @@ -0,0 +1,30 @@ +# Requirement quality harness + +Cross-project Python tools for FR/NFR quality (schema companion to `scripts/validate-requirements.js`). + +Project-specific text stays under `ai-docs//quality/`: + +| Path | Role | +| --- | --- | +| `*.yaml` | Global QC (`applies.all`) and targeted extras | +| `vocabulary.md` | Optional domain terms prepended to each Laya state | + +```bash +# Heuristic gate (any project) +python3 judge_catalog.py --project college-v2 --source heuristic + +# Or explicit paths +python3 judge_catalog.py \ + --root ai-docs/military/requirements \ + --questions ai-docs/military/quality \ + --source heuristic + +# Unit tests: synthetic catalogs + global QC from ai-docs//quality +# Discovers every pack with quality/*.yaml, or pin one: +# REQUIREMENTS_PROJECT=military python3 -m unittest discover -s . -p 'test_*.py' +python3 -m unittest discover -s . -p 'test_*.py' + +# Laya (optional) +pip install -r requirements.txt +REQUIREMENTS_PROJECT=college-v2 LAYA_SOURCE=laya ./run_laya_eval.sh +``` diff --git a/.agents/skills/manage-requirements/quality/build_gold.py b/.agents/skills/manage-requirements/quality/build_gold.py new file mode 100644 index 0000000000..2c77743ecf --- /dev/null +++ b/.agents/skills/manage-requirements/quality/build_gold.py @@ -0,0 +1,65 @@ +"""Generate targeted-extra JSONL from catalogs + question YAML (optional Laya input).""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from extract_requirement_states import iter_requirement_rows, to_jsonl_record +from load_questions import ( + global_laya_questions, + load_question_documents, + targeted_expected_by_requirement, + targeted_laya_by_requirement, + validate_questions, +) +from project import add_project_args, load_vocabulary, paths_from_args + + +def write_jsonl(path: Path, records: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + for record in records: + handle.write(json.dumps(record, ensure_ascii=False) + "\n") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + add_project_args(parser) + parser.add_argument("--extra-out", type=Path, help="Default: /extra.jsonl") + args = parser.parse_args(argv) + + paths = paths_from_args(args) + extra_out = args.extra_out or (paths.questions_dir / "extra.jsonl") + + questions = load_question_documents(paths.questions_dir) + quality_questions = global_laya_questions(questions) + vocabulary = load_vocabulary(paths.vocabulary_path) + rows = iter_requirement_rows( + paths.requirements_root, + vocabulary=vocabulary, + questions=quality_questions, + ) + errors = validate_questions(rows, paths.questions_dir) + if errors: + for err in errors: + print(f"FAIL {err}") + return 1 + + rows_by_id = {row["id"]: row for row in rows} + extra_questions = targeted_laya_by_requirement(rows, questions) + extra_expected = targeted_expected_by_requirement(rows, questions) + extra_records = [ + to_jsonl_record(rows_by_id[key], extra_expected[key], questions=extra_questions[key]) + for key in extra_questions + if key in extra_expected + ] + + write_jsonl(extra_out, extra_records) + print(f"Wrote {len(extra_records)} extra-question rows to {extra_out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/manage-requirements/quality/extract_requirement_states.py b/.agents/skills/manage-requirements/quality/extract_requirement_states.py new file mode 100644 index 0000000000..80fd8c8214 --- /dev/null +++ b/.agents/skills/manage-requirements/quality/extract_requirement_states.py @@ -0,0 +1,199 @@ +"""Walk requirements.yaml catalogs and emit one Laya state per requirement.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import yaml + +from project import REPO_ROOT, add_project_args, load_vocabulary, paths_from_args + +PLACEHOLDER_GIVEN = "the feature is available" +PLACEHOLDER_WHEN = "the behavior is exercised" + + +def collect_yaml_files(root: Path) -> list[Path]: + if root.is_file(): + return [root] + return sorted(root.rglob("requirements.yaml")) + + +def _normalize(value: object) -> str: + return " ".join(str(value or "").lower().split()) + + +def is_stub_criterion(req: dict, criterion: dict) -> bool: + given = str(criterion.get("given") or "") + when = str(criterion.get("when") or "") + then = _normalize(criterion.get("then")) + title = _normalize(req.get("title")) + placeholder = PLACEHOLDER_GIVEN in given.lower() and PLACEHOLDER_WHEN in when.lower() + then_repeats_title = bool(then) and then == title + return placeholder or then_repeats_title + + +def requirement_has_stub_ac(req: dict) -> bool: + criteria = req.get("acceptanceCriteria") or [] + return any(is_stub_criterion(req, item) for item in criteria if isinstance(item, dict)) + + +# Backward-compatible aliases. +is_tautological_criterion = is_stub_criterion +requirement_is_tautological = requirement_has_stub_ac + + +def compact_requirement(req: dict) -> dict: + payload = { + "id": req.get("id"), + "requirementType": req.get("requirementType"), + "title": req.get("title"), + "statement": req.get("statement"), + "priority": req.get("priority"), + "status": req.get("status"), + } + if req.get("acceptanceCriteria"): + payload["acceptanceCriteria"] = req["acceptanceCriteria"] + if req.get("metrics"): + payload["metrics"] = req["metrics"] + if req.get("verificationMethod"): + payload["verificationMethod"] = req["verificationMethod"] + if req.get("category"): + payload["category"] = req["category"] + return payload + + +def build_state(catalog: dict, req: dict, vocabulary: str = "") -> str: + body = yaml.safe_dump(compact_requirement(req), sort_keys=False, allow_unicode=True).strip() + parts = [] + if vocabulary.strip(): + parts.append(vocabulary.strip()) + parts.append("") + parts.extend( + [ + "Requirement catalog:", + f"specId: {catalog.get('specId')}", + f"kind: {catalog.get('kind')}", + f"title: {catalog.get('title')}", + "", + body, + "", + ] + ) + return "\n".join(parts) + + +def requirement_key(spec_id: str, req_id: str) -> str: + return f"{spec_id}#{req_id}" + + +def relative_repo_path(path: Path, repo_root: Path | None = None) -> str: + root = repo_root or REPO_ROOT + resolved = path.resolve() + try: + return resolved.relative_to(root.resolve()).as_posix() + except ValueError: + return resolved.as_posix() + + +def requirement_line_numbers(text: str) -> dict[str, int]: + """Map requirement id -> 1-based line of the `- id:` entry.""" + lines: dict[str, int] = {} + for index, line in enumerate(text.splitlines(), start=1): + stripped = line.strip() + if not stripped.startswith("- id:"): + continue + req_id = stripped.split(":", 1)[1].strip().strip("\"'") + if req_id and req_id not in lines: + lines[req_id] = index + return lines + + +def iter_requirement_rows( + root: Path, + *, + vocabulary: str = "", + questions: dict | None = None, + repo_root: Path | None = None, +) -> list[dict]: + rows = [] + for yaml_path in collect_yaml_files(root): + text = yaml_path.read_text(encoding="utf-8") + catalog = yaml.safe_load(text) + if not isinstance(catalog, dict): + continue + spec_id = catalog.get("specId") + line_by_id = requirement_line_numbers(text) + source_path = relative_repo_path(yaml_path, repo_root=repo_root) + for req in catalog.get("requirements") or []: + if not isinstance(req, dict) or not req.get("id"): + continue + req_id = str(req["id"]) + key = requirement_key(str(spec_id), req_id) + tags = req.get("tags") or [] + if not isinstance(tags, list): + tags = [tags] + rows.append( + { + "id": key, + "specId": spec_id, + "requirementId": req_id, + "title": str(req.get("title") or "").strip(), + "sourcePath": source_path, + "sourceLine": line_by_id.get(req_id), + "requirementType": req.get("requirementType"), + "kind": catalog.get("kind"), + "reqTags": [str(tag) for tag in tags], + "stub_ac": requirement_has_stub_ac(req), + "tautological": requirement_has_stub_ac(req), + "state": build_state(catalog, req, vocabulary=vocabulary), + "questions": questions if questions is not None else {}, + "tags": [str(catalog.get("kind") or "unknown"), str(spec_id)], + "language": "en", + "model": "english", + } + ) + return rows + + +def to_jsonl_record(row: dict, expected: dict | None = None, questions: dict | None = None) -> dict: + record = { + "state": row["state"], + "questions": questions if questions is not None else row["questions"], + "tags": row["tags"] + ([row["id"]]), + "language": row["language"], + "model": row["model"], + } + if expected is not None: + record["expected"] = expected + return record + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Extract requirements as Laya states.") + add_project_args(parser) + parser.add_argument("--jsonl", type=Path, help="Write one JSON object per requirement") + args = parser.parse_args(argv) + + paths = paths_from_args(args) + vocabulary = load_vocabulary(paths.vocabulary_path) + rows = iter_requirement_rows(paths.requirements_root, vocabulary=vocabulary) + if args.jsonl: + args.jsonl.parent.mkdir(parents=True, exist_ok=True) + with args.jsonl.open("w", encoding="utf-8") as handle: + for row in rows: + handle.write(json.dumps(to_jsonl_record(row), ensure_ascii=False) + "\n") + else: + json.dump( + [{"id": row["id"], "stub_ac": row["stub_ac"]} for row in rows], + sys.stdout, + indent=2, + ) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/manage-requirements/quality/judge_catalog.py b/.agents/skills/manage-requirements/quality/judge_catalog.py new file mode 100644 index 0000000000..11d615e874 --- /dev/null +++ b/.agents/skills/manage-requirements/quality/judge_catalog.py @@ -0,0 +1,347 @@ +"""Judge requirements and fail when any global QC check fails.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +from extract_requirement_states import iter_requirement_rows +from load_questions import ( + defect_criteria_from_questions, + global_laya_questions, + load_question_documents, + noul_question_ids, +) +from project import add_project_args, load_vocabulary, paths_from_args + +NOUL_FAIL_THRESHOLD = 0.5 +MAX_LOW_CONFIDENCE = 0 +_ANSI_RED = "\033[31m" +_ANSI_RESET = "\033[0m" + +DEFECT_LABELS = { + "stub_ac": "stub acceptance criteria", + "vague_then": "vague then/metric", + "vocab_drift": "vocabulary drift", + "missing_observable": "missing observable outcome", + "unbounded": "unbounded / TBD scope", +} + + +def heuristic_answers(row: dict) -> dict: + stub_ac = bool(row.get("stub_ac") if "stub_ac" in row else row.get("tautological")) + yes = 0.95 + no = 0.05 + return { + "clear": {"noul": yes, "answer_confidence": yes, "low_confidence": False}, + "verifiable": { + "noul": no if stub_ac else yes, + "answer_confidence": yes, + "low_confidence": False, + }, + "testable": { + "noul": no if stub_ac else yes, + "answer_confidence": yes, + "low_confidence": False, + }, + "consistent": {"noul": yes, "answer_confidence": yes, "low_confidence": False}, + "achievable": {"noul": yes, "answer_confidence": yes, "low_confidence": False}, + "primary_defect": { + "choice": "stub_ac" if stub_ac else "none", + "answer_confidence": yes, + "low_confidence": False, + }, + } + + +def noul_value(answer: dict) -> float: + if "noul" in answer and isinstance(answer["noul"], (int, float)): + return float(answer["noul"]) + choice = str(answer.get("choice") or "").upper() + if choice == "A": + return 1.0 + if choice == "B": + return 0.0 + return 0.0 + + +def failed_quality_reasons( + answers: dict, + noul_ids: tuple[str, ...], + min_confidence: float = 0.45, +) -> list[str]: + """Return every failed global QC question id (and low_confidence when gated).""" + reasons = [] + for key in noul_ids: + answer = answers.get(key) or {} + confidence = float(answer.get("answer_confidence") or noul_value(answer) or 0.0) + if answer.get("low_confidence") or confidence < min_confidence: + reasons.append("low_confidence") + continue + if noul_value(answer) < NOUL_FAIL_THRESHOLD: + reasons.append(key) + seen = set() + ordered = [] + for reason in reasons: + if reason not in seen: + seen.add(reason) + ordered.append(reason) + return ordered + + +def requirement_quality_failure( + answers: dict, + noul_ids: tuple[str, ...], + min_confidence: float = 0.45, +) -> tuple[bool, str | None]: + reasons = failed_quality_reasons(answers, noul_ids, min_confidence) + if not reasons: + return False, None + return True, reasons[0] + + +def primary_defect_of(answers: dict) -> str | None: + defect = answers.get("primary_defect") or {} + choice = defect.get("choice") + return str(choice) if choice else None + + +def defect_label(primary_defect: str | None) -> str | None: + if not primary_defect or primary_defect == "none": + return None + return DEFECT_LABELS.get(primary_defect, primary_defect.replace("_", " ")) + + +def failure_reason_text(primary_defect: str | None) -> str: + return defect_label(primary_defect) or "quality check failed" + + +def use_color(stream=None) -> bool: + force = (os.environ.get("FORCE_COLOR") or "").strip().lower() + if force in {"1", "true", "yes", "on"}: + return True + if force in {"0", "false", "no", "off"}: + return False + if os.environ.get("NO_COLOR"): + return False + target = stream if stream is not None else sys.stdout + return bool(getattr(target, "isatty", lambda: False)()) + + +def failed_badge(color: bool | None = None) -> str: + if color is None: + color = use_color() + if color: + return f"{_ANSI_RED}[FAILED]{_ANSI_RESET}" + return "[FAILED]" + + +def format_failure_heading(item: dict, *, color: bool | None = None) -> str: + """Build: path#line FR-ID: Title [FAILED] (falls back to catalog#id).""" + source_path = item.get("sourcePath") + source_line = item.get("sourceLine") + req_id = item.get("requirementId") or ( + str(item.get("id") or "").split("#", 1)[-1] if item.get("id") else "UNKNOWN" + ) + title = str(item.get("title") or "").strip() + if source_path and source_line: + location = f"{source_path}#{source_line}" + elif source_path: + location = str(source_path) + else: + location = str(item.get("id") or req_id) + badge = failed_badge(color) + if title: + return f"✖ {location} {req_id}: {title} {badge}" + return f"✖ {location} {req_id} {badge}" + + +def format_failure_detail( + item: dict | str, + reasons: list[str] | None = None, + primary_defect: str | None = None, + *, + color: bool | None = None, + **kwargs, +) -> list[str]: + if isinstance(item, str): + item = { + "id": item, + "reasons": reasons or [], + "primary_defect": primary_defect or kwargs.get("primary_defect"), + } + else: + reasons = reasons if reasons is not None else item.get("reasons") or [] + if primary_defect is None: + primary_defect = item.get("primary_defect") + checks = ", ".join(reasons or []) if reasons else "unknown" + return [ + format_failure_heading(item, color=color), + f"Failed checks: [{checks}]", + f"Reason: {failure_reason_text(primary_defect)}", + ] + + +def snapshot_from_rows( + rows: list[dict], + answers_by_id: dict[str, dict], + source: str, + noul_ids: tuple[str, ...], +) -> dict: + failures = [] + low_confidence = 0 + for row in rows: + answers = answers_by_id[row["id"]] + reasons = failed_quality_reasons(answers, noul_ids) + if "low_confidence" in reasons: + low_confidence += 1 + if reasons: + failures.append( + { + "id": row["id"], + "requirementId": row.get("requirementId"), + "title": row.get("title") or "", + "sourcePath": row.get("sourcePath"), + "sourceLine": row.get("sourceLine"), + "reasons": reasons, + "primary_defect": primary_defect_of(answers), + } + ) + failures.sort(key=lambda item: item["id"]) + return { + "source": source, + "failedIds": [item["id"] for item in failures], + "failures": failures, + "lowConfidenceCount": low_confidence, + "requirementCount": len(rows), + } + + +RULE = "─" * 60 + + +def format_summary(current: dict) -> list[str]: + total = int(current.get("requirementCount") or 0) + failed = len(current.get("failures") or []) + warnings = int(current.get("lowConfidenceCount") or 0) + passed = max(total - failed, 0) + return [ + RULE, + f"Requirements analyzed: {total}", + f"Passed: {passed}", + f"Failed: {failed}", + f"Warnings: {warnings}", + RULE, + ] + + +def format_quality_report( + current: dict, + defect_info: dict[str, str] | None = None, + *, + color: bool | None = None, +) -> list[str]: + """Human-readable report lines for quality failures (empty when clean).""" + failures = list(current.get("failures") or []) + if not failures and current.get("lowConfidenceCount", 0) <= MAX_LOW_CONFIDENCE: + return [] + + lines = format_summary(current) + lines.append("") + for index, item in enumerate(failures): + if index: + lines.append("") + lines.extend(format_failure_detail(item, defect_info=defect_info, color=color)) + lines.append("") + return lines + + +def report_quality_failures( + current: dict, + defect_info: dict[str, str] | None = None, + *, + color: bool | None = None, +) -> list[str]: + """Return error lines when the catalog has quality failures.""" + return format_quality_report(current, defect_info=defect_info, color=color) + + +def compare_snapshot(current: dict, committed: dict | None = None, **_kwargs) -> list[str]: + del committed + return report_quality_failures(current) + + +def predict_with_laya(rows: list[dict], min_confidence: float) -> dict[str, dict]: + from laya import Router + + router = Router() + answers_by_id = {} + for row in rows: + result = router.predict( + row["state"], + row["questions"], + model="english", + min_confidence=min_confidence, + ) + answers_by_id[row["id"]] = result["answers"] + return answers_by_id + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Judge requirement quality; fails if any FR fails global QC." + ) + add_project_args(parser) + parser.add_argument("--source", choices=("heuristic", "laya"), default="heuristic") + parser.add_argument("--min-confidence", type=float, default=0.45) + parser.add_argument("--report-only", action="store_true") + parser.add_argument("--report", type=Path) + args = parser.parse_args(argv) + + paths = paths_from_args(args) + questions = load_question_documents(paths.questions_dir) + quality_questions = global_laya_questions(questions) + noul_ids = noul_question_ids(questions) + defect_info = defect_criteria_from_questions(questions) + vocabulary = load_vocabulary(paths.vocabulary_path) + rows = iter_requirement_rows( + paths.requirements_root, + vocabulary=vocabulary, + questions=quality_questions, + ) + + if args.source == "laya": + answers_by_id = predict_with_laya(rows, args.min_confidence) + source = "laya" + else: + answers_by_id = {row["id"]: heuristic_answers(row) for row in rows} + source = "heuristic" + + current = snapshot_from_rows(rows, answers_by_id, source, noul_ids) + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8") + + report = report_quality_failures(current, defect_info=defect_info) + if args.report_only: + for line in report or format_summary(current): + print(line) + return 0 + + if report: + for line in report: + print(line) + return 1 + + for line in format_summary(current): + print(line) + print("") + print("All requirements passed global QC.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/manage-requirements/quality/judge_extra.py b/.agents/skills/manage-requirements/quality/judge_extra.py new file mode 100644 index 0000000000..b90fe5cd9e --- /dev/null +++ b/.agents/skills/manage-requirements/quality/judge_extra.py @@ -0,0 +1,98 @@ +"""Run targeted Laya questions (applies.ids / kinds / tags, not global QC).""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from extract_requirement_states import iter_requirement_rows +from load_questions import ( + global_laya_questions, + load_question_documents, + targeted_expected_by_requirement, + targeted_laya_by_requirement, + validate_questions, +) +from project import add_project_args, load_vocabulary, paths_from_args + + +def _matches(expected, answer: dict) -> bool: + if "noul" in answer and isinstance(expected, bool): + return (float(answer["noul"]) >= 0.5) is expected + if "choice" in answer: + return answer["choice"] == expected + if "score" in answer and isinstance(expected, (int, float)): + return int(round(float(answer["score"]))) == int(expected) + return False + + +def judge_extra_answers( + answers_by_id: dict[str, dict], + expected_by_id: dict[str, dict], + reserved_ids: set[str] | None = None, +) -> list[str]: + errors = [] + reserved = reserved_ids or set() + for row_id, expected in expected_by_id.items(): + answers = answers_by_id.get(row_id) or {} + for qid, value in expected.items(): + if qid in reserved: + errors.append(f"{row_id}: extra id {qid} collides with global QC") + continue + if not _matches(value, answers.get(qid) or {}): + errors.append(f"{row_id} extra.{qid}: expected {value!r}, got {answers.get(qid)!r}") + return errors + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Judge targeted per-requirement Laya questions.") + add_project_args(parser) + parser.add_argument("--report", type=Path) + args = parser.parse_args(argv) + + paths = paths_from_args(args) + questions = load_question_documents(paths.questions_dir) + quality_questions = global_laya_questions(questions) + vocabulary = load_vocabulary(paths.vocabulary_path) + rows = iter_requirement_rows( + paths.requirements_root, + vocabulary=vocabulary, + questions=quality_questions, + ) + errors = validate_questions(rows, paths.questions_dir) + if errors: + for err in errors: + print(f"FAIL {err}") + return 1 + + extra_questions = targeted_laya_by_requirement(rows, questions) + extra_expected = targeted_expected_by_requirement(rows, questions) + if not extra_questions: + print("No targeted Laya questions.") + return 0 + + from laya import Router + + rows_by_id = {row["id"]: row for row in rows} + router = Router() + answers_by_id = {} + for row_id, qset in extra_questions.items(): + result = router.predict(rows_by_id[row_id]["state"], qset, model="english") + answers_by_id[row_id] = result["answers"] + + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(answers_by_id, indent=2, default=str) + "\n", encoding="utf-8") + + mismatches = judge_extra_answers(answers_by_id, extra_expected, set(quality_questions)) + if mismatches: + for err in mismatches: + print(f"FAIL {err}") + return 1 + print(f"{len(extra_questions)} targeted Laya requirement(s) matched expected labels.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/manage-requirements/quality/load_questions.py b/.agents/skills/manage-requirements/quality/load_questions.py new file mode 100644 index 0000000000..07fc709c57 --- /dev/null +++ b/.agents/skills/manage-requirements/quality/load_questions.py @@ -0,0 +1,234 @@ +"""Load quality-question YAML tests (global QC and targeted extras).""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import yaml + +from extract_requirement_states import iter_requirement_rows +from project import add_project_args, load_vocabulary, paths_from_args + +LAYA_TYPES = {"noul", "choice", "score"} +CHECK_TYPE = "check" +METADATA_KEYS = {"id", "applies", "exclude", "expected", "checks", "path"} + + +def _as_list(value) -> list: + if value is None: + return [] + if isinstance(value, list): + return value + return [value] + + +def load_question_documents(questions_dir: Path) -> list[dict]: + if not questions_dir.is_dir(): + raise FileNotFoundError(f"Questions directory not found: {questions_dir}") + documents = [] + for path in sorted(questions_dir.glob("*.yaml")): + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + raise ValueError(f"{path} must be a YAML mapping") + qid = data.get("id") + if qid != path.stem: + raise ValueError(f"{path.name}: id {qid!r} must match filename stem {path.stem!r}") + qtype = data.get("type") + if qtype not in LAYA_TYPES | {CHECK_TYPE}: + raise ValueError(f"{path.name}: type must be noul, choice, score, or check") + data = dict(data) + data["path"] = path + documents.append(data) + if not documents: + raise ValueError(f"No question YAML files in {questions_dir}") + return documents + + +def is_global_qc(question: dict) -> bool: + applies = question.get("applies") or {} + return bool(applies.get("all")) and question.get("type") in LAYA_TYPES + + +def laya_payload(question: dict) -> dict: + payload = {} + for key, value in question.items(): + if key not in METADATA_KEYS: + payload[key] = value + return payload + + +def global_laya_questions(questions: list[dict]) -> dict: + return {q["id"]: laya_payload(q) for q in questions if is_global_qc(q)} + + +def noul_question_ids(questions: list[dict]) -> tuple[str, ...]: + return tuple(q["id"] for q in questions if is_global_qc(q) and q.get("type") == "noul") + + +def quality_expected_keys(questions: list[dict]) -> tuple[str, ...]: + return tuple(q["id"] for q in questions if is_global_qc(q)) + + +def question_applies(question: dict, row: dict) -> bool: + exclude = question.get("exclude") or {} + if row["id"] in _as_list(exclude.get("ids")): + return False + if row.get("specId") in _as_list(exclude.get("specIds")): + return False + applies = question.get("applies") or {} + if applies.get("all"): + return True + filters = False + if applies.get("ids"): + filters = True + if row["id"] not in _as_list(applies.get("ids")): + return False + if applies.get("specIds"): + filters = True + if row.get("specId") not in _as_list(applies.get("specIds")): + return False + if applies.get("kinds"): + filters = True + if row.get("kind") not in _as_list(applies.get("kinds")): + return False + if applies.get("requirementTypes"): + filters = True + if row.get("requirementType") not in _as_list(applies.get("requirementTypes")): + return False + if applies.get("tags"): + filters = True + row_tags = {str(tag) for tag in (row.get("reqTags") or [])} + if not row_tags.intersection({str(tag) for tag in _as_list(applies.get("tags"))}): + return False + return filters + + +def expected_for(question: dict, row_id: str): + expected = question.get("expected") + if isinstance(expected, dict): + return expected.get(row_id) + return expected + + +def targeted_laya_by_requirement(rows: list[dict], questions: list[dict]) -> dict[str, dict]: + by_id: dict[str, dict] = {} + for question in questions: + if question.get("type") not in LAYA_TYPES or is_global_qc(question): + continue + for row in rows: + if not question_applies(question, row): + continue + by_id.setdefault(row["id"], {})[question["id"]] = laya_payload(question) + return by_id + + +def targeted_expected_by_requirement(rows: list[dict], questions: list[dict]) -> dict[str, dict]: + by_id: dict[str, dict] = {} + for question in questions: + if question.get("type") not in LAYA_TYPES or is_global_qc(question): + continue + for row in rows: + if not question_applies(question, row): + continue + value = expected_for(question, row["id"]) + if value is None: + continue + by_id.setdefault(row["id"], {})[question["id"]] = value + return by_id + + +def defect_criteria_from_questions(questions: list[dict]) -> dict[str, str]: + for question in questions: + if question["id"] == "primary_defect": + criteria = question.get("criteria") or {} + return {str(key): str(value) for key, value in criteria.items() if key != "none"} + return {} + + +def _haystack(row: dict, check: dict) -> str: + target = check.get("in", "state") + if target == "state": + return row["state"] + raise ValueError(f"Unsupported check.in {target!r}") + + +def run_question_checks(row: dict, question: dict) -> list[str]: + errors = [] + for index, check in enumerate(question.get("checks") or []): + if not isinstance(check, dict) or "text" not in check: + errors.append(f"{question['id']}@{row['id']} checks[{index}]: needs type and text") + continue + kind = check.get("type") + needle = str(check["text"]) + found = needle.lower() in _haystack(row, check).lower() + if kind == "contains" and not found: + errors.append(f"{question['id']}@{row['id']} checks[{index}]: expected to contain {needle!r}") + elif kind == "notContains" and found: + errors.append(f"{question['id']}@{row['id']} checks[{index}]: must not contain {needle!r}") + elif kind not in {"contains", "notContains"}: + errors.append(f"{question['id']}@{row['id']} checks[{index}]: unknown type {kind!r}") + return errors + + +def validate_questions(rows: list[dict], questions_dir: Path) -> list[str]: + questions = load_question_documents(questions_dir) + errors: list[str] = [] + global_ids = [q["id"] for q in questions if is_global_qc(q)] + if not global_ids: + errors.append("no global QC questions (applies.all + noul/choice/score)") + seen = set() + rows_by_id = {row["id"]: row for row in rows} + for question in questions: + qid = question["id"] + if qid in seen: + errors.append(f"duplicate question id {qid}") + seen.add(qid) + applies = question.get("applies") or {} + if not applies.get("all") and not any( + applies.get(key) for key in ("ids", "specIds", "kinds", "requirementTypes", "tags") + ): + errors.append(f"{qid}: applies must set all: true or at least one selector") + for key in ("ids", "specIds"): + for ref in _as_list((applies.get(key) or []) + _as_list((question.get("exclude") or {}).get(key))): + if key == "ids" and ref not in rows_by_id: + errors.append(f"{qid}: applies/exclude id {ref} not found in catalogs") + if question.get("type") == CHECK_TYPE and not question.get("checks"): + errors.append(f"{qid}: check questions need checks[]") + if question.get("type") in LAYA_TYPES and not is_global_qc(question): + targets = [row for row in rows if question_applies(question, row)] + if not targets: + errors.append(f"{qid}: targeted Laya question matches no requirements") + for row in targets: + if expected_for(question, row["id"]) is None: + errors.append(f"{qid}: missing expected for {row['id']}") + for row in rows: + if question_applies(question, row) and question.get("checks"): + errors.extend(run_question_checks(row, question)) + return errors + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Validate quality question YAML against catalogs.") + add_project_args(parser) + args = parser.parse_args(argv) + paths = paths_from_args(args) + vocabulary = load_vocabulary(paths.vocabulary_path) + rows = iter_requirement_rows(paths.requirements_root, vocabulary=vocabulary) + errors = validate_questions(rows, paths.questions_dir) + if errors: + for err in errors: + print(f"FAIL {err}") + return 1 + questions = load_question_documents(paths.questions_dir) + targeted = sum(1 for q in questions if q.get("type") in LAYA_TYPES and not is_global_qc(q)) + checks = sum(1 for q in questions if q.get("type") == CHECK_TYPE) + print( + f"{len(global_laya_questions(questions))} global QC questions, " + f"{targeted} targeted Laya question(s), {checks} check question(s)." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/manage-requirements/quality/project.py b/.agents/skills/manage-requirements/quality/project.py new file mode 100644 index 0000000000..77ca6381ee --- /dev/null +++ b/.agents/skills/manage-requirements/quality/project.py @@ -0,0 +1,142 @@ +"""Resolve per-project requirements + quality-question paths under ai-docs/.""" + +from __future__ import annotations + +import argparse +import os +from dataclasses import dataclass +from pathlib import Path + +QUALITY_PKG = Path(__file__).resolve().parent +# quality/ -> manage-requirements/ -> skills/ -> .agents/ -> +REPO_ROOT = QUALITY_PKG.parents[3] + + +@dataclass(frozen=True) +class ProjectPaths: + name: str | None + requirements_root: Path + questions_dir: Path + vocabulary_path: Path + + +def resolve_project(name: str, repo_root: Path | None = None) -> ProjectPaths: + root = repo_root or REPO_ROOT + questions_dir = root / "ai-docs" / name / "quality" + return ProjectPaths( + name=name, + requirements_root=root / "ai-docs" / name / "requirements", + questions_dir=questions_dir, + vocabulary_path=questions_dir / "vocabulary.md", + ) + + +def discover_quality_projects(repo_root: Path | None = None) -> list[str]: + """Return ai-docs/ folders that contain quality/*.yaml.""" + docs = (repo_root or REPO_ROOT) / "ai-docs" + if not docs.is_dir(): + return [] + names: list[str] = [] + for path in sorted(docs.iterdir()): + if not path.is_dir() or path.name.startswith("."): + continue + quality = path / "quality" + if quality.is_dir() and any(quality.glob("*.yaml")): + names.append(path.name) + return names + + +def quality_packs_for_tests( + project: str | None = None, + *, + repo_root: Path | None = None, + environ: dict[str, str] | None = None, +) -> list[ProjectPaths]: + """ + Quality packs to exercise in harness unit tests. + + Resolution order: + 1. Explicit ``project`` argument + 2. ``REQUIREMENTS_PROJECT`` env + 3. Every discovered ``ai-docs/*/quality`` pack + """ + env = environ if environ is not None else os.environ + chosen = (project or env.get("REQUIREMENTS_PROJECT") or "").strip() + if chosen: + paths = resolve_project(chosen, repo_root=repo_root) + if not paths.questions_dir.is_dir(): + raise FileNotFoundError( + f"No quality directory for project {chosen!r}: {paths.questions_dir}" + ) + if not any(paths.questions_dir.glob("*.yaml")): + raise FileNotFoundError( + f"No quality/*.yaml for project {chosen!r}: {paths.questions_dir}" + ) + return [paths] + + names = discover_quality_projects(repo_root=repo_root) + if not names: + raise FileNotFoundError( + "No ai-docs//quality/*.yaml packs found; " + "add questions or set REQUIREMENTS_PROJECT" + ) + return [resolve_project(name, repo_root=repo_root) for name in names] + + +def load_vocabulary(path: Path | None) -> str: + if path is None or not path.is_file(): + return "" + return path.read_text(encoding="utf-8").strip() + + +def add_project_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--project", + help="Project folder under ai-docs/ (sets --root/--questions/--vocabulary)", + ) + parser.add_argument("--root", type=Path, help="Tree of requirements.yaml catalogs") + parser.add_argument( + "--questions", + type=Path, + help="Directory of quality question *.yaml files", + ) + parser.add_argument( + "--vocabulary", + type=Path, + help="Optional vocabulary markdown prepended to each Laya state", + ) + + +def paths_from_args(args: argparse.Namespace) -> ProjectPaths: + if getattr(args, "project", None): + paths = resolve_project(args.project) + root = Path(args.root) if getattr(args, "root", None) else paths.requirements_root + questions = ( + Path(args.questions) if getattr(args, "questions", None) else paths.questions_dir + ) + vocabulary = ( + Path(args.vocabulary) if getattr(args, "vocabulary", None) else paths.vocabulary_path + ) + return ProjectPaths( + name=paths.name, + requirements_root=root, + questions_dir=questions, + vocabulary_path=vocabulary, + ) + + root = getattr(args, "root", None) + questions = getattr(args, "questions", None) + if not root or not questions: + raise SystemExit("Pass --project NAME, or both --root and --questions") + + root = Path(root) + questions = Path(questions) + vocabulary = Path(args.vocabulary) if getattr(args, "vocabulary", None) else ( + questions / "vocabulary.md" + ) + return ProjectPaths( + name=None, + requirements_root=root, + questions_dir=questions, + vocabulary_path=vocabulary, + ) diff --git a/.agents/skills/manage-requirements/quality/requirements-schema.txt b/.agents/skills/manage-requirements/quality/requirements-schema.txt new file mode 100644 index 0000000000..5b9e0426eb --- /dev/null +++ b/.agents/skills/manage-requirements/quality/requirements-schema.txt @@ -0,0 +1,2 @@ +# Pinned deps for the schema/heuristic CI job (wheels only via --only-binary). +PyYAML==6.0.3 diff --git a/.agents/skills/manage-requirements/quality/requirements.txt b/.agents/skills/manage-requirements/quality/requirements.txt new file mode 100644 index 0000000000..4cc69d6646 --- /dev/null +++ b/.agents/skills/manage-requirements/quality/requirements.txt @@ -0,0 +1,5 @@ +# Optional local/CI extras for the Laya quality job. +# Schema/heuristic gates use requirements-schema.txt (pinned PyYAML). +PyYAML==6.0.3 +laya>=0.3.21,<0.4 +laya-evals>=0.1.0 diff --git a/.agents/skills/manage-requirements/quality/run_laya_eval.sh b/.agents/skills/manage-requirements/quality/run_laya_eval.sh new file mode 100755 index 0000000000..2dd5c6a39c --- /dev/null +++ b/.agents/skills/manage-requirements/quality/run_laya_eval.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Run requirement quality evals for a project (heuristic gate always; Laya when requested). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +PYTHON="${PYTHON:-python3}" +PROJECT="${REQUIREMENTS_PROJECT:-college-v2}" +SOURCE="${LAYA_SOURCE:-heuristic}" +REPORT_DIR="${REPORT_DIR:-$SCRIPT_DIR/report}" +mkdir -p "$REPORT_DIR" + +PROJECT_ARGS=(--project "$PROJECT") + +"$PYTHON" load_questions.py "${PROJECT_ARGS[@]}" +"$PYTHON" build_gold.py "${PROJECT_ARGS[@]}" +"$PYTHON" -m unittest test_quality_harness.py + +"$PYTHON" judge_catalog.py "${PROJECT_ARGS[@]}" --source heuristic --report "$REPORT_DIR/catalog_snapshot.json" + +if [[ "$SOURCE" != "laya" ]]; then + echo "Skipping Laya catalog eval (LAYA_SOURCE=$SOURCE)." + exit 0 +fi + +if ! "$PYTHON" -c "import laya" >/dev/null 2>&1; then + echo "Laya is not installed. pip install -r requirements.txt" >&2 + exit 1 +fi + +"$PYTHON" judge_catalog.py "${PROJECT_ARGS[@]}" --source laya --report "$REPORT_DIR/catalog_snapshot_laya.json" +"$PYTHON" judge_extra.py "${PROJECT_ARGS[@]}" --report "$REPORT_DIR/extra-answers.json" diff --git a/.agents/skills/manage-requirements/quality/test_quality_harness.py b/.agents/skills/manage-requirements/quality/test_quality_harness.py new file mode 100644 index 0000000000..7c2ab3b3a2 --- /dev/null +++ b/.agents/skills/manage-requirements/quality/test_quality_harness.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +import json +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(DIR)) + +from extract_requirement_states import ( + iter_requirement_rows, + requirement_has_stub_ac, + requirement_line_numbers, +) +from judge_catalog import ( + format_failure_detail, + format_quality_report, + heuristic_answers, + report_quality_failures, + snapshot_from_rows, +) +from judge_extra import judge_extra_answers +from load_questions import ( + defect_criteria_from_questions, + is_global_qc, + load_question_documents, + noul_question_ids, + question_applies, + targeted_laya_by_requirement, + validate_questions, +) +from project import ProjectPaths, quality_packs_for_tests + + +def write_yaml(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + +def pack_questions(pack: ProjectPaths) -> list[dict]: + return load_question_documents(pack.questions_dir) + + +def pack_noul_ids(pack: ProjectPaths) -> tuple[str, ...]: + return noul_question_ids(pack_questions(pack)) + + +def install_global_questions( + questions_dir: Path, + pack: ProjectPaths, + extra: dict[str, dict] | None = None, +) -> None: + """Copy applies.all question YAML from the project quality pack into the fixture.""" + questions_dir.mkdir(parents=True, exist_ok=True) + copied = 0 + for question in pack_questions(pack): + if not is_global_qc(question): + continue + src = Path(question["path"]) + shutil.copy2(src, questions_dir / src.name) + copied += 1 + if copied == 0: + raise RuntimeError( + f"No global QC questions (applies.all) in {pack.questions_dir}" + ) + for name, payload in (extra or {}).items(): + write_yaml(questions_dir / f"{name}.yaml", payload) + + +def sample_catalog(*, stub: bool = False) -> dict: + then = "X" if stub else "The matcher writes matchMode Auto or Manual" + given = ( + "The feature is available in an active sample case" + if stub + else "A course with a resolved institution is match-eligible" + ) + when = "The behavior is exercised" if stub else "Matching runs" + return { + "specId": "sample-feature", + "kind": "component", + "title": "Sample feature", + "requirements": [ + { + "id": "FR-001", + "requirementType": "Functional", + "title": "X", + "statement": "Matching records a concrete matchMode.", + "priority": "High", + "status": "Draft", + "acceptanceCriteria": [ + {"given": given, "when": when, "then": then}, + ], + }, + { + "id": "FR-002", + "requirementType": "Functional", + "title": "Observable outcome", + "statement": "Save persists the chosen articulation type.", + "priority": "High", + "status": "Draft", + "acceptanceCriteria": [ + { + "given": "A course is open in evaluation", + "when": "The reviewer saves Direct transferable equivalency", + "then": "articulation.type is Direct and acronym is T-EQ", + } + ], + }, + ], + } + + +def build_fixture( + tmp: str | Path, + pack: ProjectPaths, + *, + stub: bool = False, + extra_questions: dict[str, dict] | None = None, +) -> tuple[Path, Path, list[dict]]: + root = Path(tmp) + req_root = root / "requirements" + questions_dir = root / "quality" + write_yaml( + req_root / "components" / "sample-feature" / "requirements.yaml", + sample_catalog(stub=stub), + ) + install_global_questions(questions_dir, pack, extra_questions) + rows = iter_requirement_rows(req_root, repo_root=root) + return req_root, questions_dir, rows + + +class ProjectPackTestCase(unittest.TestCase): + """Harness tests that need a project's quality/*.yaml as the question source.""" + + def quality_packs(self) -> list[ProjectPaths]: + return quality_packs_for_tests() + + +class ExtractTests(ProjectPackTestCase): + def test_iter_rows_from_catalog(self): + for pack in self.quality_packs(): + with self.subTest(project=pack.name): + with tempfile.TemporaryDirectory() as tmp: + _, _, rows = build_fixture(tmp, pack) + self.assertEqual(len(rows), 2) + self.assertEqual( + {row["id"] for row in rows}, + {"sample-feature#FR-001", "sample-feature#FR-002"}, + ) + self.assertTrue(all(row["sourceLine"] for row in rows)) + + def test_stub_ac_detection(self): + for pack in self.quality_packs(): + with self.subTest(project=pack.name): + with tempfile.TemporaryDirectory() as good_tmp, tempfile.TemporaryDirectory() as bad_tmp: + _, _, good_rows = build_fixture(good_tmp, pack, stub=False) + _, _, bad_rows = build_fixture(bad_tmp, pack, stub=True) + by_good = {row["id"]: row for row in good_rows} + by_bad = {row["id"]: row for row in bad_rows} + self.assertFalse(by_good["sample-feature#FR-001"]["stub_ac"]) + self.assertTrue(by_bad["sample-feature#FR-001"]["stub_ac"]) + self.assertTrue( + requirement_has_stub_ac( + { + "title": "X", + "acceptanceCriteria": [ + { + "given": "The feature is available in an active sample case", + "when": "The behavior is exercised", + "then": "X", + } + ], + } + ) + ) + + +class QualityGateTests(ProjectPackTestCase): + def test_heuristic_marks_stub_ac(self): + for pack in self.quality_packs(): + with self.subTest(project=pack.name): + rows = [ + {"id": "good#FR-001", "stub_ac": False}, + {"id": "bad#FR-001", "stub_ac": True}, + ] + answers = {row["id"]: heuristic_answers(row) for row in rows} + snap = snapshot_from_rows( + rows, answers, "heuristic", pack_noul_ids(pack) + ) + self.assertEqual(snap["failedIds"], ["bad#FR-001"]) + self.assertEqual(snap["lowConfidenceCount"], 0) + + def test_report_includes_rich_failure_location(self): + current = { + "requirementCount": 2, + "failedIds": ["sample-feature#FR-001", "other-feature#FR-001"], + "failures": [ + { + "id": "sample-feature#FR-001", + "requirementId": "FR-001", + "title": "Sample rows", + "sourcePath": "requirements/components/sample-feature/requirements.yaml", + "sourceLine": 12, + "reasons": ["testable", "verifiable"], + "primary_defect": "stub_ac", + }, + { + "id": "other-feature#FR-001", + "requirementId": "FR-001", + "title": "Other entry", + "sourcePath": "requirements/screens/other-feature/requirements.yaml", + "sourceLine": 10, + "reasons": ["testable", "verifiable"], + "primary_defect": "stub_ac", + }, + ], + "lowConfidenceCount": 0, + } + report = "\n".join(format_quality_report(current, color=False)) + self.assertIn("Requirements analyzed: 2", report) + self.assertIn("Passed: 0", report) + self.assertIn("Failed: 2", report) + self.assertIn( + "✖ requirements/components/sample-feature/requirements.yaml#12 FR-001: Sample rows [FAILED]", + report, + ) + self.assertIn("Failed checks: [testable, verifiable]", report) + self.assertIn("Reason: stub acceptance criteria", report) + + def test_clean_catalog_has_no_report(self): + current = { + "requirementCount": 1, + "failedIds": [], + "failures": [], + "lowConfidenceCount": 0, + } + self.assertEqual(report_quality_failures(current), []) + + def test_format_failure_detail_includes_quality_ids(self): + lines = format_failure_detail( + { + "id": "sample-feature#FR-002", + "requirementId": "FR-002", + "title": "Observable outcome", + "sourcePath": "requirements/components/sample-feature/requirements.yaml", + "sourceLine": 28, + "reasons": ["verifiable", "testable"], + "primary_defect": "stub_ac", + }, + color=False, + ) + self.assertEqual( + lines, + [ + "✖ requirements/components/sample-feature/requirements.yaml#28 FR-002: Observable outcome [FAILED]", + "Failed checks: [verifiable, testable]", + "Reason: stub acceptance criteria", + ], + ) + + def test_source_line_matches_yaml_id_entry(self): + text = ( + "requirements:\n" + " - id: FR-001\n" + " title: First\n" + " - id: FR-002\n" + " title: Second\n" + ) + lines = requirement_line_numbers(text) + self.assertEqual(lines["FR-001"], 2) + self.assertEqual(lines["FR-002"], 4) + + def test_fixture_stub_failures_appear_in_report(self): + for pack in self.quality_packs(): + with self.subTest(project=pack.name): + with tempfile.TemporaryDirectory() as tmp: + _, questions_dir, rows = build_fixture(tmp, pack, stub=True) + questions = load_question_documents(questions_dir) + answers = {row["id"]: heuristic_answers(row) for row in rows} + current = snapshot_from_rows( + rows, answers, "heuristic", noul_question_ids(questions) + ) + report = report_quality_failures( + current, defect_info=defect_criteria_from_questions(questions) + ) + self.assertTrue(report) + self.assertTrue( + any("stub acceptance criteria" in line for line in report) + ) + self.assertIn("sample-feature#FR-001", current["failedIds"]) + self.assertNotIn("sample-feature#FR-002", current["failedIds"]) + + +class QuestionYamlTests(ProjectPackTestCase): + def test_quality_packs_have_global_qc(self): + packs = self.quality_packs() + self.assertTrue(packs) + for pack in packs: + with self.subTest(project=pack.name): + questions = pack_questions(pack) + globals_ = [q for q in questions if is_global_qc(q)] + self.assertTrue(pack.questions_dir.is_dir()) + self.assertGreaterEqual(len(globals_), 1) + self.assertTrue(pack_noul_ids(pack)) + + def test_questions_validate_against_fixture_catalog(self): + for pack in self.quality_packs(): + with self.subTest(project=pack.name): + with tempfile.TemporaryDirectory() as tmp: + _, questions_dir, rows = build_fixture( + tmp, + pack, + extra_questions={ + "uses_match_mode": { + "id": "uses_match_mode", + "type": "check", + "applies": {"ids": ["sample-feature#FR-001"]}, + "checks": [{"type": "contains", "text": "matchMode"}], + }, + "save_writes_type": { + "id": "save_writes_type", + "type": "noul", + "applies": {"ids": ["sample-feature#FR-002"]}, + "instructions": "Does save write articulation.type?", + "expected": True, + }, + }, + ) + self.assertEqual(validate_questions(rows, questions_dir), []) + questions = load_question_documents(questions_dir) + extra = targeted_laya_by_requirement(rows, questions) + self.assertEqual(set(extra), {"sample-feature#FR-002"}) + self.assertIn("save_writes_type", extra["sample-feature#FR-002"]) + + def test_global_applies_to_every_requirement(self): + row = { + "id": "x#FR-001", + "specId": "x", + "kind": "script", + "requirementType": "NonFunctional", + "reqTags": [], + } + self.assertTrue(question_applies({"applies": {"all": True}}, row)) + self.assertFalse(question_applies({"applies": {"ids": ["other#FR-001"]}}, row)) + self.assertTrue(question_applies({"applies": {"kinds": ["script"]}}, row)) + self.assertFalse(question_applies({"applies": {"kinds": ["screen"]}}, row)) + + def test_unknown_applies_id_fails(self): + for pack in self.quality_packs(): + with self.subTest(project=pack.name): + with tempfile.TemporaryDirectory() as tmp: + _, questions_dir, rows = build_fixture(tmp, pack) + write_yaml( + questions_dir / "scoped.yaml", + { + "id": "scoped", + "type": "noul", + "applies": {"ids": ["sample-feature#FR-999"]}, + "instructions": "x", + "expected": True, + }, + ) + errors = validate_questions(rows, questions_dir) + self.assertTrue(any("FR-999" in err for err in errors)) + + def test_check_contains_missing_text_fails(self): + for pack in self.quality_packs(): + with self.subTest(project=pack.name): + with tempfile.TemporaryDirectory() as tmp: + _, questions_dir, rows = build_fixture(tmp, pack) + write_yaml( + questions_dir / "bad_check.yaml", + { + "id": "bad_check", + "type": "check", + "applies": {"ids": ["sample-feature#FR-001"]}, + "checks": [{"type": "contains", "text": "ace_id"}], + }, + ) + errors = validate_questions(rows, questions_dir) + self.assertTrue(any("expected to contain" in err for err in errors)) + + def test_extra_answer_mismatch(self): + errors = judge_extra_answers( + {"sample-feature#FR-002": {"save_writes_type": {"noul": 0.1}}}, + {"sample-feature#FR-002": {"save_writes_type": True}}, + ) + self.assertEqual(len(errors), 1) + + def test_extra_cannot_reuse_global_ids(self): + for pack in self.quality_packs(): + with self.subTest(project=pack.name): + reserved = pack_noul_ids(pack)[0] + errors = judge_extra_answers( + {"x#FR-001": {reserved: {"noul": 0.9}}}, + {"x#FR-001": {reserved: True}}, + reserved_ids={reserved}, + ) + self.assertTrue(any("collides" in err for err in errors)) + + +class GoldBuildTests(ProjectPackTestCase): + def test_build_gold_writes_extra_jsonl(self): + import build_gold + + for pack in self.quality_packs(): + with self.subTest(project=pack.name): + with tempfile.TemporaryDirectory() as tmp: + req_root, questions_dir, _ = build_fixture( + tmp, + pack, + extra_questions={ + "save_writes_type": { + "id": "save_writes_type", + "type": "noul", + "applies": {"ids": ["sample-feature#FR-002"]}, + "instructions": "Does save write articulation.type?", + "expected": True, + }, + }, + ) + extra = Path(tmp) / "extra.jsonl" + self.assertEqual( + build_gold.main( + [ + "--root", + str(req_root), + "--questions", + str(questions_dir), + "--extra-out", + str(extra), + ] + ), + 0, + ) + extra_lines = [ + json.loads(line) + for line in extra.read_text(encoding="utf-8").splitlines() + if line + ] + self.assertEqual(len(extra_lines), 1) + self.assertIn("save_writes_type", extra_lines[0]["questions"]) + for global_id in pack_noul_ids(pack): + self.assertNotIn(global_id, extra_lines[0]["questions"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/.agents/skills/manage-requirements/schemas/requirement.schema.json b/.agents/skills/manage-requirements/schemas/requirement.schema.json new file mode 100644 index 0000000000..164d2e2245 --- /dev/null +++ b/.agents/skills/manage-requirements/schemas/requirement.schema.json @@ -0,0 +1,196 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "requirement.schema.json", + "title": "Requirement", + "type": "object", + "required": [ + "id", + "requirementType", + "title", + "statement", + "priority", + "status" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^(FR|NFR)-[0-9]{3,}$" + }, + "requirementType": { + "type": "string", + "enum": ["Functional", "NonFunctional"] + }, + "title": { + "type": "string", + "minLength": 3 + }, + "statement": { + "type": "string", + "minLength": 10 + }, + "description": { + "type": "string" + }, + "priority": { + "type": "string", + "enum": ["Critical", "High", "Medium", "Low"] + }, + "status": { + "type": "string", + "enum": [ + "Draft", + "Proposed", + "Approved", + "Implemented", + "Verified", + "Deprecated" + ] + }, + "source": { + "type": "string" + }, + "rationale": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "category": { + "type": "string" + }, + "acceptanceCriteria": { + "type": "array", + "items": { + "$ref": "#/$defs/acceptanceCriterion" + } + }, + "metrics": { + "type": "array", + "items": { + "$ref": "#/$defs/metric" + } + }, + "verificationMethod": { + "type": "string", + "enum": ["Test", "Inspection", "Analysis", "Demonstration", "Audit"] + }, + "dependencies": { + "type": "array", + "items": { + "type": "string" + } + }, + "constraints": { + "type": "array", + "items": { + "type": "string" + } + }, + "assumptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "traceability": { + "$ref": "#/$defs/traceability" + } + }, + "$defs": { + "acceptanceCriterion": { + "type": "object", + "required": ["given", "when", "then"], + "properties": { + "given": { + "type": "string" + }, + "when": { + "type": "string" + }, + "then": { + "type": "string" + } + } + }, + "metric": { + "type": "object", + "required": ["name", "operator", "value"], + "properties": { + "name": { + "type": "string" + }, + "operator": { + "type": "string", + "enum": ["<", "<=", "=", ">=", ">"] + }, + "value": { + "type": ["number", "string"] + }, + "unit": { + "type": "string" + } + } + }, + "traceability": { + "type": "object", + "properties": { + "businessObjectives": { + "type": "array", + "items": { + "type": "string" + } + }, + "epics": { + "type": "array", + "items": { + "type": "string" + } + }, + "features": { + "type": "array", + "items": { + "type": "string" + } + }, + "testCases": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "requirementType": { + "const": "Functional" + } + } + }, + "then": { + "required": ["acceptanceCriteria"] + } + }, + { + "if": { + "properties": { + "requirementType": { + "const": "NonFunctional" + } + } + }, + "then": { + "required": ["category", "metrics", "verificationMethod"] + } + } + ] +} diff --git a/.agents/skills/manage-requirements/schemas/requirements-catalog.schema.json b/.agents/skills/manage-requirements/schemas/requirements-catalog.schema.json new file mode 100644 index 0000000000..73214d09ad --- /dev/null +++ b/.agents/skills/manage-requirements/schemas/requirements-catalog.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "requirements-catalog.schema.json", + "title": "RequirementsCatalog", + "description": "Feature-level catalog of requirements for a reusable component, screen, or script. Cross-project; not tied to a single domain.", + "type": "object", + "required": ["specId", "kind", "title", "requirements"], + "additionalProperties": false, + "properties": { + "specId": { + "type": "string", + "minLength": 2, + "description": "Stable feature id, usually the folder basename (e.g. articulation-section)." + }, + "kind": { + "type": "string", + "enum": ["component", "screen", "script"] + }, + "title": { + "type": "string", + "minLength": 3 + }, + "description": { + "type": "string" + }, + "config": { + "type": "object", + "description": "Declarative knobs the feature exposes so requirements stay reusable (props, ports, section ids).", + "additionalProperties": true + }, + "dependsOn": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Other catalog specId values this feature composes (e.g. screens depending on components)." + }, + "requirements": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "requirement.schema.json" + } + } + } +} diff --git a/.agents/skills/manage-requirements/scripts/__tests__/validate-requirements.test.js b/.agents/skills/manage-requirements/scripts/__tests__/validate-requirements.test.js new file mode 100644 index 0000000000..948ad890dc --- /dev/null +++ b/.agents/skills/manage-requirements/scripts/__tests__/validate-requirements.test.js @@ -0,0 +1,209 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test, beforeEach, afterEach } = require('node:test'); + +const { + collectAcceptanceQuality, + collectYamlFiles, + isStubCriterion, + main, + validateFiles, + validateMarkdownPair, +} = require('../validate-requirements'); + +const VALID_YAML = `specId: example-feature +kind: component +title: Example feature +description: Short summary of what this catalog covers. +config: + props: + - readonly +dependsOn: [] +requirements: + - id: FR-001 + requirementType: Functional + title: Example behavior + statement: The component must expose the configured behavior without hard-coding a specific screen. + priority: High + status: Draft + tags: [example] + acceptanceCriteria: + - given: The component is configured with readonly true + when: The user opens the feature + then: Editing controls are disabled +`; + +function writeCatalog(dir, { specId = 'example-feature', yaml = VALID_YAML, markdown = true } = {}) { + const featureDir = path.join(dir, specId); + fs.mkdirSync(featureDir, { recursive: true }); + fs.writeFileSync(path.join(featureDir, 'requirements.yaml'), yaml, 'utf8'); + if (markdown) { + fs.writeFileSync(path.join(featureDir, `${specId}.md`), `# ${specId}\n\nPurpose.\n`, 'utf8'); + } + return path.join(featureDir, 'requirements.yaml'); +} + +let tmpDir; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'req-validate-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('collectYamlFiles finds nested requirements.yaml', () => { + writeCatalog(tmpDir); + const files = collectYamlFiles(tmpDir); + assert.equal(files.length, 1); + assert.match(files[0], /example-feature\/requirements\.yaml$/); +}); + +test('valid catalog with companion markdown passes', () => { + const yamlPath = writeCatalog(tmpDir); + const [result] = validateFiles([yamlPath]); + assert.equal(result.ok, true); + assert.deepEqual(result.errors, []); + assert.deepEqual(result.warnings, []); +}); + +test('YAML parse error fails the catalog', () => { + const yamlPath = writeCatalog(tmpDir, { + yaml: 'specId: [this is: broken', + }); + const [result] = validateFiles([yamlPath]); + assert.equal(result.ok, false); + assert.match(result.errors[0], /YAML parse error/); +}); + +test('duplicate requirement ids fail', () => { + const yaml = VALID_YAML.replace( + 'then: Editing controls are disabled\n', + `then: Editing controls are disabled + - id: FR-001 + requirementType: Functional + title: Duplicate behavior + statement: Another statement that is long enough for the schema. + priority: High + status: Draft + acceptanceCriteria: + - given: The component is configured with readonly false + when: The user opens the feature + then: Editing controls remain enabled +` + ); + const yamlPath = writeCatalog(tmpDir, { yaml }); + const [result] = validateFiles([yamlPath]); + assert.equal(result.ok, false); + assert.ok(result.errors.some((err) => /duplicate id "FR-001"/.test(err))); +}); + +test('functional requirement without acceptanceCriteria fails schema', () => { + const yaml = `specId: example-feature +kind: component +title: Example feature +requirements: + - id: FR-001 + requirementType: Functional + title: Example behavior + statement: The component must expose the configured behavior without hard-coding a specific screen. + priority: High + status: Draft +`; + const yamlPath = writeCatalog(tmpDir, { yaml }); + const [result] = validateFiles([yamlPath]); + assert.equal(result.ok, false); + assert.ok(result.errors.some((err) => /required/.test(err))); +}); + +test('missing companion markdown fails', () => { + const yamlPath = writeCatalog(tmpDir, { markdown: false }); + const [result] = validateFiles([yamlPath]); + assert.equal(result.ok, false); + assert.ok(result.errors.some((err) => /missing companion markdown/.test(err))); + assert.ok(validateMarkdownPair(yamlPath, { specId: 'example-feature' }).length > 0); +}); + +test('README.md satisfies the markdown pair', () => { + const yamlPath = writeCatalog(tmpDir, { markdown: false }); + fs.writeFileSync(path.join(path.dirname(yamlPath), 'README.md'), '# Example\n', 'utf8'); + const [result] = validateFiles([yamlPath]); + assert.equal(result.ok, true); +}); + +test('short given/when/then warn instead of failing', () => { + const yaml = VALID_YAML.replace( + 'given: The component is configured with readonly true', + 'given: short' + ); + const yamlPath = writeCatalog(tmpDir, { yaml }); + const [result] = validateFiles([yamlPath]); + assert.equal(result.ok, true); + assert.ok(result.warnings.some((warn) => /given shorter than 8/.test(warn))); +}); + +test('empty given/when/then fail', () => { + const yaml = VALID_YAML.replace( + 'given: The component is configured with readonly true', + 'given: " "' + ); + const yamlPath = writeCatalog(tmpDir, { yaml }); + const [result] = validateFiles([yamlPath]); + assert.equal(result.ok, false); + assert.ok(result.errors.some((err) => /given must not be empty/.test(err))); +}); + +test('placeholder given/when is a stub AC warning, not a hard fail', () => { + const yaml = VALID_YAML.replace( + ` - given: The component is configured with readonly true + when: The user opens the feature + then: Editing controls are disabled +`, + ` - given: The feature is available in an active CollegeV2 case + when: The behavior is exercised + then: Editing controls are disabled +` + ); + const yamlPath = writeCatalog(tmpDir, { yaml }); + const [result] = validateFiles([yamlPath]); + assert.equal(result.ok, true); + assert.ok(result.warnings.some((warn) => /stub acceptance criterion/.test(warn))); +}); + +test('then that repeats the title is a stub AC', () => { + assert.equal( + isStubCriterion( + { title: 'Hide student search in Web Entry' }, + { given: 'A screen is open today', when: 'The reviewer continues', then: 'Hide student search in Web Entry' } + ), + true + ); + const quality = collectAcceptanceQuality({ + requirements: [ + { + id: 'FR-001', + title: 'Hide student search in Web Entry', + acceptanceCriteria: [ + { + given: 'A screen is open today', + when: 'The reviewer continues', + then: 'Hide student search in Web Entry', + }, + ], + }, + ], + }); + assert.equal(quality.errors.length, 0); + assert.equal(quality.warnings.length, 1); +}); + +test('CLI main returns 0 for a valid tree and 1 when markdown is missing', () => { + writeCatalog(tmpDir); + assert.equal(main([tmpDir]), 0); + const brokenDir = path.join(tmpDir, 'broken'); + writeCatalog(brokenDir, { specId: 'missing-md', markdown: false }); + assert.equal(main([brokenDir]), 1); +}); diff --git a/.agents/skills/manage-requirements/scripts/check-requirements.js b/.agents/skills/manage-requirements/scripts/check-requirements.js new file mode 100644 index 0000000000..3f18b65bf6 --- /dev/null +++ b/.agents/skills/manage-requirements/scripts/check-requirements.js @@ -0,0 +1,154 @@ +#!/usr/bin/env node +/** + * Run schema validation + heuristic quality judging for requirement catalogs. + * + * Usage: + * node check-requirements.js + * node check-requirements.js college-v2 + * node check-requirements.js --project military + * + * With no project arg, checks every ai-docs//requirements tree. + * Project may also come from REQUIREMENTS_PROJECT when no CLI project is given. + */ + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const SKILL_ROOT = path.resolve(__dirname, '..'); +const REPO_ROOT = path.resolve(SKILL_ROOT, '../../..'); +const AI_DOCS = path.join(REPO_ROOT, 'ai-docs'); +const VALIDATE_JS = path.join(__dirname, 'validate-requirements.js'); +const JUDGE_PY = path.join(SKILL_ROOT, 'quality', 'judge_catalog.py'); + +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + cyan: '\x1b[36m', +}; + +function log(message, color = 'reset') { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +function parseArgs(argv) { + let project = null; + const rest = []; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--project' || arg === '-p') { + project = argv[i + 1] || null; + i += 1; + continue; + } + if (arg.startsWith('--project=')) { + project = arg.slice('--project='.length) || null; + continue; + } + if (arg.startsWith('-')) { + rest.push(arg); + continue; + } + if (project === null) { + project = arg; + continue; + } + rest.push(arg); + } + if (!project && process.env.REQUIREMENTS_PROJECT) { + project = process.env.REQUIREMENTS_PROJECT; + } + return { project, rest }; +} + +function listRequirementProjects() { + if (!fs.existsSync(AI_DOCS)) { + return []; + } + return fs + .readdirSync(AI_DOCS, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => fs.existsSync(path.join(AI_DOCS, name, 'requirements'))) + .sort(); +} + +function hasQualityFixtures(project) { + const qualityDir = path.join(AI_DOCS, project, 'quality'); + if (!fs.existsSync(qualityDir)) { + return false; + } + return fs.readdirSync(qualityDir).some((name) => name.endsWith('.yaml')); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd || REPO_ROOT, + env: { ...process.env, FORCE_COLOR: process.env.FORCE_COLOR || '1' }, + stdio: 'inherit', + }); + if (result.error) { + log(result.error.message, 'red'); + return 1; + } + return typeof result.status === 'number' ? result.status : 1; +} + +function main(argv = process.argv.slice(2)) { + const { project } = parseArgs(argv); + const projects = project ? [project] : listRequirementProjects(); + + if (projects.length === 0) { + log('No ai-docs/*/requirements trees found.', 'red'); + return 1; + } + + if (project) { + const reqRoot = path.join(AI_DOCS, project, 'requirements'); + if (!fs.existsSync(reqRoot)) { + log(`Requirements tree missing: ${path.relative(REPO_ROOT, reqRoot)}`, 'red'); + return 1; + } + } + + const schemaTargets = projects.map((name) => path.join(AI_DOCS, name, 'requirements')); + log(`Schema: validating ${projects.join(', ')}...`, 'cyan'); + const schemaStatus = run(process.execPath, [VALIDATE_JS, ...schemaTargets]); + if (schemaStatus !== 0) { + return schemaStatus; + } + + let qualityFailed = 0; + for (const name of projects) { + if (!hasQualityFixtures(name)) { + log(`Quality: skip ${name} (no ai-docs/${name}/quality/*.yaml)`, 'yellow'); + continue; + } + log(`Quality: judging ${name} (heuristic)...`, 'cyan'); + const status = run('python3', [JUDGE_PY, '--project', name, '--source', 'heuristic']); + if (status !== 0) { + qualityFailed += 1; + } + } + + if (qualityFailed > 0) { + log(`${qualityFailed} project quality gate(s) failed.`, 'red'); + return 1; + } + + log('Schema and quality checks passed.', 'green'); + return 0; +} + +module.exports = { + hasQualityFixtures, + listRequirementProjects, + main, + parseArgs, +}; + +if (require.main === module) { + process.exit(main()); +} diff --git a/.agents/skills/manage-requirements/scripts/validate-requirements.js b/.agents/skills/manage-requirements/scripts/validate-requirements.js new file mode 100644 index 0000000000..692dcb927b --- /dev/null +++ b/.agents/skills/manage-requirements/scripts/validate-requirements.js @@ -0,0 +1,390 @@ +#!/usr/bin/env node +/** + * Validate requirements.yaml catalogs against the manage-requirements schemas. + * + * Usage: + * node .agents/skills/manage-requirements/scripts/validate-requirements.js [path...] + * + * Paths may be a requirements.yaml file, a feature directory, or a tree root. + * Default: every ai-docs//requirements tree that exists. + */ + +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const Ajv = require('ajv/dist/2020'); +const addFormats = require('ajv-formats'); + +const SKILL_ROOT = path.resolve(__dirname, '..'); +const SCHEMA_DIR = path.join(SKILL_ROOT, 'schemas'); +const REPO_ROOT = path.resolve(SKILL_ROOT, '../../..'); +const AI_DOCS = path.join(REPO_ROOT, 'ai-docs'); +const AC_FIELD_MIN_LENGTH = 8; + +function defaultTargets() { + if (!fs.existsSync(AI_DOCS)) { + return []; + } + return fs + .readdirSync(AI_DOCS, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(AI_DOCS, entry.name, 'requirements')) + .filter((dir) => fs.existsSync(dir)) + .sort(); +} + +const DEFAULT_TARGET = path.join(REPO_ROOT, 'ai-docs/college-v2/requirements'); +const PLACEHOLDER_GIVEN = /the feature is available/i; +const PLACEHOLDER_WHEN = /the behavior is exercised/i; + +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + cyan: '\x1b[36m', + bright: '\x1b[1m', +}; + +function log(message, color = 'reset') { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +function loadJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function createAjv() { + const ajv = new Ajv({ + allErrors: true, + strict: false, + validateSchema: true, + }); + addFormats(ajv); + + const requirementSchema = loadJson(path.join(SCHEMA_DIR, 'requirement.schema.json')); + const catalogSchema = loadJson(path.join(SCHEMA_DIR, 'requirements-catalog.schema.json')); + + ajv.addSchema(requirementSchema); + ajv.addSchema(catalogSchema); + + return { + ajv, + validateCatalog: ajv.getSchema('requirements-catalog.schema.json'), + }; +} + +function collectYamlFiles(targetPath) { + const abs = path.resolve(targetPath); + if (!fs.existsSync(abs)) { + throw new Error(`Path not found: ${abs}`); + } + + const stat = fs.statSync(abs); + if (stat.isFile()) { + if (!abs.endsWith('.yaml') && !abs.endsWith('.yml')) { + throw new Error(`Not a YAML file: ${abs}`); + } + return [abs]; + } + + if (stat.isDirectory()) { + const direct = path.join(abs, 'requirements.yaml'); + if (fs.existsSync(direct)) { + return [direct]; + } + + const found = []; + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name === 'requirements.yaml' || entry.name === 'requirements.yml') { + found.push(full); + } + } + }; + walk(abs); + return found.sort(); + } + + throw new Error(`Unsupported path: ${abs}`); +} + +function formatAjvErrors(errors) { + if (!errors || errors.length === 0) { + return ['Unknown validation error']; + } + return errors.map((err) => { + const instance = err.instancePath || '/'; + return `${instance} ${err.message}${err.params ? ` ${JSON.stringify(err.params)}` : ''}`; + }); +} + +function normalizeComparable(value) { + return String(value || '') + .toLowerCase() + .replace(/\s+/g, ' ') + .trim(); +} + +function companionMarkdownPath(filePath, specId) { + const dir = path.dirname(filePath); + const candidates = []; + if (typeof specId === 'string' && specId.length > 0) { + candidates.push(path.join(dir, `${specId}.md`)); + } + candidates.push(path.join(dir, 'README.md')); + return candidates.find((candidate) => fs.existsSync(candidate)) || null; +} + +function validateMarkdownPair(filePath, data) { + const specId = data && typeof data.specId === 'string' ? data.specId : null; + if (companionMarkdownPath(filePath, specId)) { + return []; + } + const expected = specId ? `${specId}.md or README.md` : '.md or README.md'; + return [`missing companion markdown (${expected}) in ${path.dirname(filePath)}`]; +} + +function isStubCriterion(req, criterion) { + const given = String(criterion.given || ''); + const when = String(criterion.when || ''); + const then = normalizeComparable(criterion.then); + const title = normalizeComparable(req.title); + const placeholderPair = PLACEHOLDER_GIVEN.test(given) && PLACEHOLDER_WHEN.test(when); + const thenRepeatsTitle = then.length > 0 && then === title; + return placeholderPair || thenRepeatsTitle; +} + +/** @deprecated Use isStubCriterion */ +function isTautologicalCriterion(req, criterion) { + return isStubCriterion(req, criterion); +} + +function collectAcceptanceQuality(data) { + const errors = []; + const warnings = []; + if (!data || !Array.isArray(data.requirements)) { + return { errors, warnings }; + } + + data.requirements.forEach((req, reqIndex) => { + if (!req || !Array.isArray(req.acceptanceCriteria)) { + return; + } + req.acceptanceCriteria.forEach((criterion, acIndex) => { + if (!criterion || typeof criterion !== 'object') { + return; + } + ['given', 'when', 'then'].forEach((field) => { + const value = typeof criterion[field] === 'string' ? criterion[field].trim() : ''; + const instance = `/requirements/${reqIndex}/acceptanceCriteria/${acIndex}/${field}`; + if (value.length === 0) { + errors.push(`${instance} must not be empty`); + } else if (value.length < AC_FIELD_MIN_LENGTH) { + warnings.push( + `${instance} shorter than ${AC_FIELD_MIN_LENGTH} characters` + ); + } + }); + if (isStubCriterion(req, criterion)) { + const id = typeof req.id === 'string' ? req.id : `#${reqIndex}`; + warnings.push( + `/requirements/${reqIndex} ${id} stub acceptance criterion (placeholder given/when or then repeats title)` + ); + } + }); + }); + + return { errors, warnings }; +} + +function validateCatalogDocument(filePath, data, validateCatalog) { + const errors = []; + + if (!validateCatalog(data)) { + for (const msg of formatAjvErrors(validateCatalog.errors)) { + errors.push(msg); + } + } + + if (data && Array.isArray(data.requirements)) { + const seen = new Set(); + data.requirements.forEach((req, index) => { + if (!req || typeof req.id !== 'string') { + return; + } + if (seen.has(req.id)) { + errors.push(`/requirements/${index}/id duplicate id "${req.id}" within catalog`); + } + seen.add(req.id); + }); + } + + return errors; +} + +function indexSpecIds(yamlFiles) { + const bySpecId = new Map(); + for (const file of yamlFiles) { + try { + const data = yaml.load(fs.readFileSync(file, 'utf8')); + if (data && typeof data.specId === 'string') { + bySpecId.set(data.specId, file); + } + } catch { + // parse errors reported during validation + } + } + return bySpecId; +} + +function validateDependsOn(data, filePath, specIndex) { + const errors = []; + if (!data || !Array.isArray(data.dependsOn)) { + return errors; + } + for (const dep of data.dependsOn) { + if (!specIndex.has(dep)) { + errors.push( + `dependsOn "${dep}" not found among catalogs in this validation run (referenced from ${path.relative(REPO_ROOT, filePath)})` + ); + } + } + return errors; +} + +function validateFiles(files, options = {}) { + const { validateCatalog } = options.validateCatalog + ? { validateCatalog: options.validateCatalog } + : createAjv(); + if (!validateCatalog) { + throw new Error('Failed to compile requirements-catalog.schema.json'); + } + + const uniqueFiles = [...new Set(files)]; + const specIndex = indexSpecIds(uniqueFiles); + const results = []; + + for (const file of uniqueFiles) { + let data; + try { + data = yaml.load(fs.readFileSync(file, 'utf8')); + } catch (err) { + results.push({ + file, + ok: false, + errors: [`YAML parse error: ${err.message}`], + warnings: [], + data: null, + }); + continue; + } + + const quality = collectAcceptanceQuality(data); + const errors = [ + ...validateCatalogDocument(file, data, validateCatalog), + ...validateMarkdownPair(file, data), + ...validateDependsOn(data, file, specIndex), + ...quality.errors, + ]; + + results.push({ + file, + ok: errors.length === 0, + errors, + warnings: quality.warnings, + data, + }); + } + + return results; +} + +function main(argv = process.argv.slice(2)) { + const targets = argv.length > 0 ? argv : defaultTargets(); + + if (targets.length === 0) { + log('No ai-docs/*/requirements trees found.', 'red'); + log('Pass an explicit path to a requirements.yaml, feature dir, or tree.', 'yellow'); + return 1; + } + + let files = []; + try { + for (const target of targets) { + files = files.concat(collectYamlFiles(target)); + } + } catch (err) { + log(err.message, 'red'); + return 1; + } + + files = [...new Set(files)]; + + if (files.length === 0) { + log('No requirements.yaml files found.', 'yellow'); + return 1; + } + + log(`Validating ${files.length} catalog(s)...`, 'cyan'); + const results = validateFiles(files); + let failed = 0; + let warningCount = 0; + + for (const result of results) { + const rel = path.relative(REPO_ROOT, result.file) || result.file; + if (!result.ok) { + failed += 1; + log(`FAIL ${rel}`, 'red'); + for (const err of result.errors) { + log(` ${err}`, 'red'); + } + } else { + const count = result.data && Array.isArray(result.data.requirements) + ? result.data.requirements.length + : 0; + const specId = result.data && result.data.specId ? result.data.specId : 'unknown'; + log(`OK ${rel} (${specId}, ${count} requirements)`, 'green'); + } + for (const warning of result.warnings) { + warningCount += 1; + log(` WARN ${warning}`, 'yellow'); + } + } + + console.log(''); + if (failed > 0) { + log(`${failed} of ${results.length} catalog(s) failed.`, 'red'); + return 1; + } + + log(`All ${results.length} catalog(s) passed.`, 'green'); + if (warningCount > 0) { + log(`${warningCount} acceptance-criterion warning(s).`, 'yellow'); + } + return 0; +} + +module.exports = { + AC_FIELD_MIN_LENGTH, + DEFAULT_TARGET, + REPO_ROOT, + collectAcceptanceQuality, + collectYamlFiles, + companionMarkdownPath, + createAjv, + isStubCriterion, + isTautologicalCriterion, + main, + validateCatalogDocument, + validateDependsOn, + validateFiles, + validateMarkdownPair, +}; + +if (require.main === module) { + process.exit(main()); +} diff --git a/.agents/skills/manage-requirements/templates/feature.md.example b/.agents/skills/manage-requirements/templates/feature.md.example new file mode 100644 index 0000000000..2d9d02915e --- /dev/null +++ b/.agents/skills/manage-requirements/templates/feature.md.example @@ -0,0 +1,22 @@ +# Example feature + +Narrative for humans and agents. Do not duplicate every FR statement here. + +## Purpose + +What this feature does and who uses it. + +## Kind and reuse + +- `kind`: component | screen | script +- Configurable via `config` in `requirements.yaml` +- Screens compose components through `dependsOn` + +## Related assets + +- Vue / BPMN / script paths as applicable +- Legacy design or edge-case docs during migration + +## Configuration + +Describe how consumers configure this feature (props, ports, section order). diff --git a/.agents/skills/manage-requirements/templates/requirements.yaml.example b/.agents/skills/manage-requirements/templates/requirements.yaml.example new file mode 100644 index 0000000000..1f6cd435f0 --- /dev/null +++ b/.agents/skills/manage-requirements/templates/requirements.yaml.example @@ -0,0 +1,23 @@ +specId: example-feature +kind: component +title: Example feature +description: Short summary of what this catalog covers. +config: + props: + - readonly + - allowedActions +dependsOn: [] +requirements: + - id: FR-001 + requirementType: Functional + title: Example behavior + statement: The component must expose the configured behavior without hard-coding a specific screen. + priority: High + status: Draft + tags: [example, reusable] + acceptanceCriteria: + - given: The component is configured with readonly true + when: The user opens the feature + then: Editing controls are disabled + traceability: + testCases: [] diff --git a/ProcessMaker/Console/Commands/PluginInstallCommand.php b/ProcessMaker/Console/Commands/PluginInstallCommand.php new file mode 100644 index 0000000000..29eb3f58dc --- /dev/null +++ b/ProcessMaker/Console/Commands/PluginInstallCommand.php @@ -0,0 +1,81 @@ +argument('repo'); + $branch = $this->option('branch'); + $tag = $this->option('tag'); + + // Set up event listener to output log messages + $listener = function (PluginLog $event) { + $message = $event->message; + if ($event->pluginName) { + $message = "[{$event->pluginName}] {$message}"; + } + + switch ($event->status) { + case 'done': + $this->info($message); + break; + case 'error': + $this->error($message); + break; + case 'running': + default: + $this->line($message); + break; + } + + // Stop listening if we get a 'done' or 'error' status + if (in_array($event->status, ['done', 'error'])) { + Event::forget(PluginLog::class); + } + }; + + Event::listen(PluginLog::class, $listener); + + try { + $manager = new PluginManager(); + $manager->install($repo, $branch, $tag); + + return Command::SUCCESS; + } catch (RuntimeException $e) { + $this->error('Installation failed: ' . $e->getMessage()); + event(new PluginLog('Installation failed: ' . $e->getMessage(), 'error')); + + return Command::FAILURE; + } finally { + // Clean up listener + Event::forget(PluginLog::class); + } + } +} diff --git a/ProcessMaker/Console/Commands/PluginListCommand.php b/ProcessMaker/Console/Commands/PluginListCommand.php new file mode 100644 index 0000000000..5e7d43bde5 --- /dev/null +++ b/ProcessMaker/Console/Commands/PluginListCommand.php @@ -0,0 +1,55 @@ +list(); + + if (empty($plugins)) { + $this->info('No plugins installed.'); + + return Command::SUCCESS; + } + + $headers = ['Name', 'Description', 'Branch/Tag']; + $rows = []; + + foreach ($plugins as $plugin) { + $rows[] = [ + $plugin['name'], + $plugin['description'], + $plugin['branch'] ?? 'N/A', + ]; + } + + $this->table($headers, $rows); + + return Command::SUCCESS; + } +} diff --git a/ProcessMaker/Console/Commands/PluginUninstallCommand.php b/ProcessMaker/Console/Commands/PluginUninstallCommand.php new file mode 100644 index 0000000000..4b3038a7f6 --- /dev/null +++ b/ProcessMaker/Console/Commands/PluginUninstallCommand.php @@ -0,0 +1,79 @@ +argument('name'); + + // Set up event listener to output log messages + $listener = function (PluginLog $event) { + $message = $event->message; + if ($event->pluginName) { + $message = "[{$event->pluginName}] {$message}"; + } + + switch ($event->status) { + case 'done': + $this->info($message); + break; + case 'error': + $this->error($message); + break; + case 'running': + default: + $this->line($message); + break; + } + + // Stop listening if we get a 'done' or 'error' status + if (in_array($event->status, ['done', 'error'])) { + Event::forget(PluginLog::class); + } + }; + + Event::listen(PluginLog::class, $listener); + + try { + $manager = new PluginManager(); + $manager->uninstall($name); + + return Command::SUCCESS; + } catch (RuntimeException $e) { + $this->error('Uninstallation failed: ' . $e->getMessage()); + event(new PluginLog('Uninstallation failed: ' . $e->getMessage(), 'error', $name)); + + return Command::FAILURE; + } finally { + // Clean up listener + Event::forget(PluginLog::class); + } + } +} diff --git a/ProcessMaker/Events/PluginLog.php b/ProcessMaker/Events/PluginLog.php new file mode 100644 index 0000000000..6855c8f0b9 --- /dev/null +++ b/ProcessMaker/Events/PluginLog.php @@ -0,0 +1,65 @@ +message = $message; + $this->status = $status; + $this->pluginName = $pluginName; + $this->userId = $userId; + } + + public function broadcastAs() + { + return 'PluginLog'; + } + + public function broadcastOn() + { + return new PrivateChannel('ProcessMaker.Models.User.' . $this->userId); + } +} diff --git a/ProcessMaker/Http/Controllers/Admin/PluginController.php b/ProcessMaker/Http/Controllers/Admin/PluginController.php new file mode 100644 index 0000000000..2cf2e5ea77 --- /dev/null +++ b/ProcessMaker/Http/Controllers/Admin/PluginController.php @@ -0,0 +1,14 @@ +route('file-manager.index'); case 'view-settings': return redirect()->route('settings.index'); + case 'view-plugins': + return redirect()->route('plugins.index'); default: throw new AuthorizationException(); } diff --git a/ProcessMaker/Http/Controllers/Api/PluginController.php b/ProcessMaker/Http/Controllers/Api/PluginController.php new file mode 100644 index 0000000000..b536de23c0 --- /dev/null +++ b/ProcessMaker/Http/Controllers/Api/PluginController.php @@ -0,0 +1,79 @@ +manager->list(); + + return response()->json(['data' => $plugins]); + } + + public function install(Request $request): JsonResponse + { + $request->validate([ + 'url' => 'required_without:zip|nullable|string', + 'zip' => 'required_without:url|nullable|file|mimes:zip', + 'branch' => 'prohibits:tag|nullable|string', + 'tag' => 'prohibits:branch|nullable|string', + ]); + + $userId = $request->user()->id; + + if ($request->hasFile('zip')) { + $tmpPath = $request->file('zip')->store('plugin-uploads', 'local'); + $fullPath = storage_path('app/' . $tmpPath); + + InstallPlugin::dispatch( + userId: $userId, + zipPath: $fullPath, + ); + } else { + InstallPlugin::dispatch( + userId: $userId, + url: $request->input('url'), + branch: $request->input('branch'), + tag: $request->input('tag'), + ); + } + + return response()->json([ + 'queued' => true, + 'message' => 'Plugin installation queued', + ]); + } + + public function destroy(string $name, Request $request): JsonResponse + { + UninstallPlugin::dispatch($name, $request->user()->id); + + return response()->json([ + 'queued' => true, + 'message' => 'Plugin uninstallation queued', + ]); + } + + public function toggle(string $name, Request $request): JsonResponse + { + TogglePlugin::dispatch($name, $request->user()->id); + + return response()->json([ + 'queued' => true, + 'message' => 'Plugin toggle queued', + ]); + } +} diff --git a/ProcessMaker/Http/Middleware/GenerateMenus.php b/ProcessMaker/Http/Middleware/GenerateMenus.php index f60536d283..caf1cf5e7b 100644 --- a/ProcessMaker/Http/Middleware/GenerateMenus.php +++ b/ProcessMaker/Http/Middleware/GenerateMenus.php @@ -122,6 +122,10 @@ public function handle(Request $request, Closure $next) 'icon' => 'fa-code', ]); } + $submenu->add(__('Plugins'), [ + 'route' => 'plugins.index', + 'icon' => 'fa-plug', + ]); if (config('app.case_retention_policy_enabled')) { $submenu->add(__('Cases Retention Logs'), [ diff --git a/ProcessMaker/Jobs/InstallPlugin.php b/ProcessMaker/Jobs/InstallPlugin.php new file mode 100644 index 0000000000..73b04f1277 --- /dev/null +++ b/ProcessMaker/Jobs/InstallPlugin.php @@ -0,0 +1,67 @@ +releaseAfter(30) + ->expireAfter(630), + ]; + } + + public function handle(PluginManager $manager): void + { + $label = $this->zipPath ? basename($this->zipPath) : ($this->url ?? 'plugin'); + event(new PluginLog('Starting queued plugin installation...', 'starting', $label, $this->userId)); + + if ($this->zipPath) { + $manager->installFromZip($this->zipPath, $this->userId); + + return; + } + + $manager->install($this->url, $this->branch, $this->tag, $this->userId); + } + + public function failed(Throwable $exception): void + { + $label = $this->zipPath ? basename((string) $this->zipPath) : ($this->url ?? 'plugin'); + event(new PluginLog( + 'Plugin installation failed: ' . $exception->getMessage(), + 'error', + $label, + $this->userId + )); + } +} diff --git a/ProcessMaker/Jobs/TogglePlugin.php b/ProcessMaker/Jobs/TogglePlugin.php new file mode 100644 index 0000000000..63998fe9e7 --- /dev/null +++ b/ProcessMaker/Jobs/TogglePlugin.php @@ -0,0 +1,68 @@ +releaseAfter(30) + ->expireAfter(630), + ]; + } + + public function handle(PluginManager $manager): void + { + event(new PluginLog( + "Starting queued plugin toggle: {$this->pluginName}", + 'starting', + $this->pluginName, + $this->userId + )); + + $enabled = $manager->toggle($this->pluginName, $this->userId); + $state = $enabled ? 'enabled' : 'disabled'; + event(new PluginLog( + "Plugin {$this->pluginName} {$state}", + 'done', + $this->pluginName, + $this->userId + )); + } + + public function failed(Throwable $exception): void + { + event(new PluginLog( + 'Plugin toggle failed: ' . $exception->getMessage(), + 'error', + $this->pluginName, + $this->userId + )); + } +} diff --git a/ProcessMaker/Jobs/UninstallPlugin.php b/ProcessMaker/Jobs/UninstallPlugin.php new file mode 100644 index 0000000000..e024471ab3 --- /dev/null +++ b/ProcessMaker/Jobs/UninstallPlugin.php @@ -0,0 +1,61 @@ +releaseAfter(30) + ->expireAfter(630), + ]; + } + + public function handle(PluginManager $manager): void + { + event(new PluginLog( + "Starting queued plugin uninstallation: {$this->pluginName}", + 'starting', + $this->pluginName, + $this->userId + )); + + $manager->uninstall($this->pluginName, $this->userId); + } + + public function failed(Throwable $exception): void + { + event(new PluginLog( + 'Plugin uninstallation failed: ' . $exception->getMessage(), + 'error', + $this->pluginName, + $this->userId + )); + } +} diff --git a/ProcessMaker/Managers/PluginManager.php b/ProcessMaker/Managers/PluginManager.php new file mode 100644 index 0000000000..780ce71b22 --- /dev/null +++ b/ProcessMaker/Managers/PluginManager.php @@ -0,0 +1,902 @@ +resolveLocalPath($repoUrl); + $isLocalPath = $localPath !== null; + + if ($isLocalPath) { + // Extract plugin name from path + $repoName = basename($localPath); + $pluginPath = storage_path('plugins') . '/' . $repoName; + + $this->logRunning("Starting installation of plugin from local path: {$repoName}", $repoName, $userId); + + $resolvedLocalPath = realpath($localPath); + $resolvedPluginPath = realpath($pluginPath); + + $alreadyAtTarget = $resolvedLocalPath + && $resolvedPluginPath + && $resolvedLocalPath === $resolvedPluginPath + && !is_link($pluginPath); + + if ($alreadyAtTarget) { + $this->logRunning('Plugin is already installed at the target path', $repoName, $userId); + } else { + if (!is_dir(storage_path('plugins'))) { + mkdir(storage_path('plugins'), 0755, true); + } + + if (is_link($pluginPath)) { + if (!@unlink($pluginPath)) { + throw new RuntimeException("Failed to remove existing plugin symlink: {$pluginPath}"); + } + } elseif (is_dir($pluginPath)) { + $this->logRunning('Removing existing plugin directory...', $repoName, $userId); + $this->deleteDirectory($pluginPath, $repoName, $userId); + } + + $this->logRunning('Copying plugin from local path...', $repoName, $userId); + $this->copyDirectory($localPath, $pluginPath, $repoName, $userId); + } + } else { + // Extract repository name from URL + $repoName = $this->extractRepoName($repoUrl); + $pluginPath = storage_path('plugins') . '/' . $repoName; + + $this->logRunning("Starting installation of plugin: {$repoName}", $repoName, $userId); + + if (is_dir($pluginPath)) { + $this->updateExistingRepository($pluginPath, $repoName, $branch, $tag, $userId); + } else { + $this->logRunning('Cloning repository...', $repoName, $userId); + $this->cloneRepository($repoUrl, $pluginPath, $repoName, $userId); + + if ($branch) { + $this->logRunning("Checking out branch: {$branch}", $repoName, $userId); + $this->checkoutBranch($pluginPath, $branch, $repoName, $userId); + } elseif ($tag) { + $this->logRunning("Checking out tag: {$tag}", $repoName, $userId); + $this->checkoutTag($pluginPath, $tag, $repoName, $userId); + } + } + } + + // Validate composer.json + $this->logRunning('Validating plugin...', $repoName, $userId); + $this->validatePlugin($pluginPath, $repoName, $userId); + + // Run composer install + $this->logRunning('Installing dependencies...', $repoName, $userId); + $this->runComposerInstall($pluginPath, $repoName, $userId); + + // Run plugin install command if it exists + + $installCommand = "{$repoName}:install"; + + $this->logRunning('installCommand: ' . $installCommand, $repoName, $userId); + + $this->logRunning('Running plugin install command...', $repoName, $userId); + $this->runCommand($installCommand, $repoName, $userId); + + $this->refreshRuntimeAfterPluginChange($repoName, $userId); + + $this->logDone('Plugin installed successfully', $repoName, $userId); + } + + /** + * Uninstall a plugin. + * + * @param string $pluginName + * @return void + * @throws RuntimeException + */ + public function uninstall(string $pluginName, ?int $userId = null): void + { + $pluginDir = storage_path('plugins'); + $pluginPath = "{$pluginDir}/{$pluginName}"; + $pluginDisabledPath = "{$pluginDir}/_{$pluginName}"; + + if (is_dir($pluginDisabledPath) || is_link($pluginDisabledPath)) { + $pluginPath = $pluginDisabledPath; + } elseif (!is_dir($pluginPath) && !is_link($pluginPath)) { + throw new RuntimeException("Plugin not found: {$pluginName}"); + } + + $this->logRunning("Starting uninstallation of plugin: {$pluginName}", $pluginName, $userId); + + // Run plugin uninstall command if it exists + $uninstallCommand = "{$pluginName}:uninstall"; + $this->logRunning('Running plugin uninstall command...', $pluginName, $userId); + $this->runCommand($uninstallCommand, $pluginName, $userId); + + $this->logRunning('Removing plugin directory...', $pluginName, $userId); + $this->removePluginPath($pluginPath, $pluginName, $userId); + + $this->refreshRuntimeAfterPluginChange($pluginName, $userId); + + $this->logDone('Plugin uninstalled successfully', $pluginName, $userId); + } + + private function runCommand(string $command, string $pluginName, ?int $userId = null): void + { + // Use absolute path: web SAPI (php-fpm) cwd is not the app root, so plain "artisan" fails. + //TODO: Change artisan_binary() for base_path('artisan') + $artisan = base_path('artisan'); + $result = Process::env($this->artisanEnvironment()) + ->timeout(300) + ->run(array_filter([ + php_binary(), + $artisan, + $command, + ])); + + if (!$result->successful()) { + if (str_contains($result->output(), 'is not defined')) { + $this->logRunning("Plugin does not have a {$command} command", $pluginName, $userId); + } else { + $this->logError("Plugin {$command} command failed. Got output:\n\n{$result->output()}", $pluginName, $userId); + } + } else { + $this->logRunning("Plugin {$command} command output:\n\n{$result->output()}", $pluginName, $userId); + } + } + + /** + * Refresh routes and queue workers after plugin code changes. + */ + private function refreshRuntimeAfterPluginChange(string $pluginName, ?int $userId = null): void + { + $this->rebuildRouteCache($pluginName, $userId); + $this->terminateHorizon($pluginName, $userId); + } + + /** + * Rebuild the tenant's route cache in a fresh artisan process. + * + * The current request already booted without the plugin's providers, so + * Artisan::call('route:cache') would persist stale routes. A subprocess + * with TENANT set loads plugins from the tenant storage path and writes + * the cache for that tenant. + */ + private function rebuildRouteCache(string $pluginName, ?int $userId = null): void + { + $this->logRunning('Rebuilding route cache...', $pluginName, $userId); + $this->runCommand('route:cache', $pluginName, $userId); + $this->reloadCachedRoutes(); + } + + /** + * Always stop current Horizon workers so a new process picks up plugin code. + */ + private function terminateHorizon(string $pluginName, ?int $userId = null): void + { + $this->logRunning('Restarting queue workers...', $pluginName, $userId); + $this->runCommand('horizon:terminate', $pluginName, $userId); + } + + /** + * Environment variables for artisan subprocesses started from a web request. + */ + private function artisanEnvironment(): array + { + $environment = []; + $tenant = app()->bound('currentTenant') ? app('currentTenant') : null; + + if (config('app.multitenancy') && $tenant) { + $environment['TENANT'] = (string) $tenant->id; + } + + return $environment; + } + + /** + * Load the freshly written route cache into the current process. + * + * Needed for Octane workers that keep the router in memory after + * `route:cache` finishes in a subprocess. + */ + private function reloadCachedRoutes(): void + { + $cachedRoutesPath = app()->getCachedRoutesPath(); + if (!is_file($cachedRoutesPath)) { + return; + } + + require $cachedRoutesPath; + } + + /** + * Install a plugin from a zip file path. + * + * @param string $zipPath Full path to the uploaded zip file + * @return void + * @throws RuntimeException + */ + public function installFromZip(string $zipPath, ?int $userId = null): void + { + if (!file_exists($zipPath)) { + throw new RuntimeException("Zip file not found: {$zipPath}"); + } + + $zip = new \ZipArchive(); + $openResult = $zip->open($zipPath); + + if ($openResult !== true) { + throw new RuntimeException("Failed to open zip file: {$zipPath} (error code: {$openResult})"); + } + + $tmpExtractDir = sys_get_temp_dir() . '/pm-plugin-' . uniqid(); + + if (!mkdir($tmpExtractDir, 0755, true)) { + throw new RuntimeException("Failed to create temp directory: {$tmpExtractDir}"); + } + + try { + $zip->extractTo($tmpExtractDir); + $zip->close(); + + // Find the actual plugin directory (zip may have a top-level folder) + $entries = array_diff(scandir($tmpExtractDir), ['.', '..']); + $pluginSourceDir = $tmpExtractDir; + + if (count($entries) === 1) { + $singleEntry = $tmpExtractDir . '/' . reset($entries); + if (is_dir($singleEntry)) { + $pluginSourceDir = $singleEntry; + } + } + + // Validate before copying + $repoName = basename($pluginSourceDir); + $this->validatePlugin($pluginSourceDir, $repoName, $userId); + + $pluginPath = storage_path('plugins') . '/' . $repoName; + + $this->logRunning("Installing plugin from zip: {$repoName}", $repoName, $userId); + // Ensure plugins directory exists + if (!is_dir(storage_path('plugins'))) { + mkdir(storage_path('plugins'), 0755, true); + } + + // Remove existing plugin directory if present + if (is_dir($pluginPath)) { + $this->deleteDirectory($pluginPath, $repoName, $userId); + } + + // Copy extracted directory to plugins folder + $this->copyDirectory($pluginSourceDir, $pluginPath, $repoName, $userId); + + // Run composer install + $this->logRunning('Installing dependencies...', $repoName, $userId); + $this->runComposerInstall($pluginPath, $repoName, $userId); + + // Run plugin install command if it exists + $installCommand = "{$repoName}:install"; + $this->logRunning('Running plugin install command...', $repoName, $userId); + $this->runCommand($installCommand, $repoName, $userId); + + $this->refreshRuntimeAfterPluginChange($repoName, $userId); + + $this->logDone('Plugin installed successfully', $repoName, $userId); + } finally { + // Always clean up temp directory and zip file + if (is_dir($tmpExtractDir)) { + $this->deleteDirectory($tmpExtractDir, $repoName, $userId); + } + if (file_exists($zipPath)) { + unlink($zipPath); + } + } + } + + /** + * Toggle a plugin enabled/disabled by renaming its directory. + * Disabled plugins have a `_` prefix on their folder name. + * + * @param string $pluginName + * @return bool True if now enabled, false if now disabled + * @throws RuntimeException + */ + public function toggle(string $pluginName, ?int $userId = null): bool + { + $pluginsFolder = storage_path('plugins'); + + // Check if currently enabled (no underscore prefix) + $enabledPath = $pluginsFolder . '/' . $pluginName; + $disabledName = '_' . ltrim($pluginName, '_'); + $disabledPath = $pluginsFolder . '/' . $disabledName; + + if (is_dir($enabledPath)) { + // Disable it + if (!rename($enabledPath, $disabledPath)) { + $this->logError("Failed to disable plugin: {$pluginName}", $pluginName, $userId); + throw new RuntimeException("Failed to disable plugin: {$pluginName}"); + } + + $this->refreshRuntimeAfterPluginChange($pluginName, $userId); + + return false; + } + + if (is_dir($disabledPath)) { + // Enable it + $enabledName = ltrim($pluginName, '_'); + $enabledPath = $pluginsFolder . '/' . $enabledName; + if (!rename($disabledPath, $enabledPath)) { + $this->logError("Failed to enable plugin: {$pluginName}", $pluginName, $userId); + throw new RuntimeException("Failed to enable plugin: {$pluginName}"); + } + + $this->refreshRuntimeAfterPluginChange($enabledName, $userId); + + return true; + } + + $this->logError("Plugin not found: {$pluginName}", $pluginName, $userId); + throw new RuntimeException("Plugin not found: {$pluginName}"); + } + + /** + * List all installed plugins, including disabled ones. + * + * @return array + */ + public function list(): array + { + $pluginsFolder = storage_path('plugins'); + $plugins = []; + + if (!is_dir($pluginsFolder)) { + return $plugins; + } + + $pluginPaths = glob($pluginsFolder . '/*', GLOB_ONLYDIR); + + foreach ($pluginPaths as $pluginPath) { + $dirName = basename($pluginPath); + $isDisabled = str_starts_with($dirName, '_'); + + try { + $plugin = Plugin::fromPath($pluginPath); + $reference = $plugin->getReference(); + $composerJson = $plugin->getComposerJson(); + + $plugins[] = [ + 'name' => $isDisabled ? ltrim($dirName, '_') : $dirName, + 'url' => $plugin->getUrl(), + 'folder' => $dirName, + 'description' => $plugin->getDescription() ?? 'No description', + 'version' => $composerJson['version'] ?? null, + 'branch' => $reference, + 'enabled' => !$isDisabled ? 'Enabled' : 'Disabled', + ]; + } catch (\Exception $e) { + // Skip invalid plugins + continue; + } + } + + return $plugins; + } + + /** + * Extract repository name from URL. + * + * @param string $repoUrl + * @return string + */ + protected function extractRepoName(string $repoUrl): string + { + // Remove only a single trailing '.git' or '/' if present + $repoUrl = preg_replace('/(\.git|\/)$/', '', $repoUrl); + + // Extract name from URL + if (preg_match('#/([^/]+)$#', $repoUrl, $matches)) { + return $matches[1]; + } + + throw new RuntimeException("Could not extract repository name from URL: {$repoUrl}"); + } + + /** + * Clone a repository. + * + * @param string $repoUrl + * @param string $destination + * @return void + * @throws RuntimeException + */ + protected function cloneRepository(string $repoUrl, string $destination, string $repoName, ?int $userId = null): void + { + // Handle GITHUB_TOKEN if present + $url = $this->prepareGitUrl($repoUrl); + + $process = Process::timeout(300)->run(['git', 'clone', $url, $destination]); + + if (!$process->successful()) { + $this->logError('Failed to clone repository: ' . $process->errorOutput(), $repoName, $userId); + throw new RuntimeException('Failed to clone repository: ' . $process->errorOutput()); + } + } + + /** + * Pull latest changes from repository. + * + * @param string $pluginPath + * @return void + * @throws RuntimeException + */ + /** + * Fetch remotes and check out the requested ref when the plugin clone already exists. + */ + protected function updateExistingRepository( + string $pluginPath, + string $repoName, + ?string $branch, + ?string $tag, + ?int $userId = null + ): void { + $this->logRunning('Plugin directory exists, fetching from remote...', $repoName, $userId); + $this->fetchRepository($pluginPath, $repoName, $userId); + + if ($tag) { + $this->logRunning("Checking out tag: {$tag}", $repoName, $userId); + $this->checkoutTag($pluginPath, $tag, $repoName, $userId); + + return; + } + + $branchToCheckout = $branch ?? $this->resolveRemoteDefaultBranch($pluginPath, $repoName, $userId); + $this->logRunning("Checking out branch: {$branchToCheckout}", $repoName, $userId); + $this->checkoutBranch($pluginPath, $branchToCheckout, $repoName, $userId); + $this->logRunning("Pulling latest changes for branch {$branchToCheckout}...", $repoName, $userId); + $this->pullRepository($pluginPath, $repoName, $userId, $branchToCheckout); + } + + protected function fetchRepository(string $pluginPath, string $repoName, ?int $userId = null): void + { + $process = Process::path($pluginPath)->timeout(300)->run(['git', 'fetch', '--all', '--tags']); + + if (!$process->successful()) { + $this->logError('Failed to fetch repository: ' . $process->errorOutput(), $repoName, $userId); + throw new RuntimeException('Failed to fetch repository: ' . $process->errorOutput()); + } + } + + protected function resolveRemoteDefaultBranch(string $pluginPath, string $repoName, ?int $userId = null): string + { + $process = Process::path($pluginPath)->timeout(60)->run(['git', 'rev-parse', '--abbrev-ref', 'origin/HEAD']); + + if ($process->successful()) { + $ref = trim($process->output()); + if (str_starts_with($ref, 'origin/')) { + return substr($ref, strlen('origin/')); + } + if ($ref !== '' && $ref !== 'HEAD') { + return $ref; + } + } + + foreach (['main', 'master', 'develop'] as $fallbackBranch) { + $check = Process::path($pluginPath)->run([ + 'git', + 'show-ref', + '--verify', + '--quiet', + "refs/remotes/origin/{$fallbackBranch}", + ]); + if ($check->successful()) { + return $fallbackBranch; + } + } + + $this->logError('Could not determine default branch for plugin repository', $repoName, $userId); + throw new RuntimeException('Could not determine default branch for plugin repository'); + } + + protected function pullRepository(string $pluginPath, string $repoName, ?int $userId = null, ?string $branch = null): void + { + $command = $branch === null + ? ['git', 'pull'] + : ['git', 'pull', 'origin', $branch]; + + $process = Process::path($pluginPath)->timeout(300)->run($command); + + if (!$process->successful()) { + $this->logError('Failed to pull repository: ' . $process->errorOutput(), $repoName, $userId); + throw new RuntimeException('Failed to pull repository: ' . $process->errorOutput()); + } + } + + /** + * Checkout a branch. + * + * @param string $pluginPath + * @param string $branch + * @return void + * @throws RuntimeException + */ + protected function checkoutBranch(string $pluginPath, string $branch, string $repoName, ?int $userId = null): void + { + $process = Process::path($pluginPath)->timeout(120)->run(['git', 'checkout', $branch]); + + if (!$process->successful()) { + $this->logError('Failed to checkout branch: ' . $process->errorOutput(), $repoName, $userId); + throw new RuntimeException("Failed to checkout branch {$branch}: " . $process->errorOutput()); + } + } + + /** + * Checkout a tag. + * + * @param string $pluginPath + * @param string $tag + * @return void + * @throws RuntimeException + */ + protected function checkoutTag(string $pluginPath, string $tag, string $repoName, ?int $userId = null): void + { + $process = Process::path($pluginPath)->timeout(120)->run(['git', 'checkout', "tags/{$tag}"]); + + if (!$process->successful()) { + $this->logError('Failed to checkout tag: ' . $process->errorOutput(), $repoName, $userId); + throw new RuntimeException("Failed to checkout tag {$tag}: " . $process->errorOutput()); + } + } + + /** + * Validate that the plugin has a valid composer.json structure. + * + * @param string $pluginPath + * @return void + * @throws RuntimeException + */ + protected function validatePlugin(string $pluginPath, string $repoName, ?int $userId = null): void + { + $composerJsonPath = $pluginPath . '/composer.json'; + + if (!file_exists($composerJsonPath)) { + $this->logError('composer.json not found in plugin: ' . $pluginPath, $repoName, $userId); + throw new RuntimeException("composer.json not found in plugin: {$pluginPath}"); + } + + $content = file_get_contents($composerJsonPath); + $composerJson = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + $this->logError('Invalid JSON in composer.json at ' . $composerJsonPath . ': ' . json_last_error_msg(), $repoName, $userId); + throw new RuntimeException( + "Invalid JSON in composer.json at {$composerJsonPath}: " . json_last_error_msg() + ); + } + + // Check if require or require-dev is an empty array ([]), if so change for empty object and save to file + $changed = false; + if (Arr::get($composerJson, 'require', []) === []) { + $composerJson['require'] = new \stdClass(); + $changed = true; + } + if (Arr::get($composerJson, 'require-dev', []) === []) { + $composerJson['require-dev'] = new \stdClass(); + $changed = true; + } + if ($changed) { + file_put_contents($composerJsonPath, json_encode($composerJson, JSON_PRETTY_PRINT)); + } + + // Check PSR-4 autoload namespace + $psr4Namespaces = Arr::get($composerJson, 'autoload.psr-4', []); + + if (empty($psr4Namespaces)) { + $this->logError('Plugin must have PSR-4 autoload configuration in composer.json', $repoName, $userId); + throw new RuntimeException('Plugin must have PSR-4 autoload configuration in composer.json'); + } + + $valid = false; + foreach ($psr4Namespaces as $namespace => $path) { + $prefixes = [ + 'ProcessMaker\\Plugins\\', + 'ProcessMaker\\Package\\', + 'ProcessMaker\\Packages\\', + ]; + $valid = (bool) collect($prefixes)->first(fn ($prefix) => str_starts_with($namespace, $prefix)); + if ($valid) { + break; + } + // if (str_starts_with($namespace, 'ProcessMaker\\Plugins\\')) { + // $valid = true; + // break; + // } + // if (str_starts_with($namespace, 'ProcessMaker\\Package\\')) { + // $valid = true; + // break; + // } + // //TODO: Check if needs to be package or packages or both + // if (str_starts_with($namespace, 'ProcessMaker\\Packages\\')) { + // $valid = true; + // break; + // } + } + + if (!$valid) { + $this->logError('Plugin PSR-4 namespace must start with ' . implode(', ', $prefixes) . '. Found: ' . implode(', ', array_keys($psr4Namespaces)), $repoName, $userId); + throw new RuntimeException( + "Plugin PSR-4 namespace must start with 'ProcessMaker\\Plugins\\'. Found: " . + implode(', ', array_keys($psr4Namespaces)) + ); + } + } + + /** + * Fetch branches from the repository. + * + * @param string $pluginPath + * @return void + * @throws RuntimeException + */ + protected function fetchBranches(string $pluginPath, string $repoName, ?int $userId = null): void + { + $process = Process::path($pluginPath)->timeout(60)->run(['git', 'branch', '-a']); + if (!$process->successful()) { + $this->logError('Failed to fetch branches: ' . $process->errorOutput(), $repoName, $userId); + throw new RuntimeException('Failed to fetch branches: ' . $process->errorOutput()); + } + } + + /** + * Run composer install in the plugin directory. + * + * @param string $pluginPath + * @return void + * @throws RuntimeException + */ + protected function runComposerInstall(string $pluginPath, string $repoName, ?int $userId = null): void + { + set_time_limit(0); + $process = Process::path($pluginPath) + ->forever() + ->env([ + 'PATH' => '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:' . (getenv('PATH') ?: ''), + ]) + ->run(['composer', 'install', '--no-interaction']); + + if (!$process->successful()) { + $this->logError('Failed to run composer install: ' . $process->errorOutput(), $repoName, $userId); + throw new RuntimeException('Failed to run composer install: ' . $process->errorOutput()); + } + } + + /** + * Copy a directory recursively. + * + * @param string $source + * @param string $destination + * @return void + * @throws RuntimeException + */ + protected function copyDirectory(string $source, string $destination, string $repoName, ?int $userId = null): void + { + if (!is_dir($destination)) { + mkdir($destination, 0755, true); + } + + $files = array_diff(scandir($source), ['.', '..']); + + foreach ($files as $file) { + $srcPath = $source . '/' . $file; + $destPath = $destination . '/' . $file; + + if (is_dir($srcPath)) { + $this->copyDirectory($srcPath, $destPath, $repoName, $userId); + } else { + if (!copy($srcPath, $destPath)) { + $this->logError('Failed to copy file: ' . $srcPath . ' to ' . $destPath, $repoName, $userId); + throw new RuntimeException("Failed to copy file: {$srcPath} to {$destPath}"); + } + } + } + } + + /** + * Remove a plugin from storage/plugins without deleting symlink targets. + * + * @throws RuntimeException + */ + protected function removePluginPath(string $pluginPath, string $pluginName, ?int $userId = null): void + { + if (is_link($pluginPath)) { + $this->logRunning('Removing plugin symlink (source directory is kept)...', $pluginName, $userId); + if (!@unlink($pluginPath)) { + $this->logError('Failed to remove plugin symlink: ' . $pluginPath, $pluginName, $userId); + throw new RuntimeException("Failed to remove plugin symlink: {$pluginPath}"); + } + + return; + } + + $this->deleteDirectory($pluginPath, $pluginName, $userId); + } + + /** + * Delete a directory recursively. + * + * @param string $directory + * @return void + * @throws RuntimeException + */ + protected function deleteDirectory(string $directory, string $repoName, ?int $userId = null): void + { + if (is_link($directory)) { + if (!@unlink($directory)) { + $this->logError('Failed to remove symlink: ' . $directory, $repoName, $userId); + throw new RuntimeException("Failed to remove symlink: {$directory}"); + } + + return; + } + + if (!is_dir($directory)) { + return; + } + + $files = array_diff(scandir($directory), ['.', '..']); + + foreach ($files as $file) { + $path = $directory . '/' . $file; + if (is_dir($path) && !is_link($path)) { + $this->deleteDirectory($path, $repoName, $userId); + } else { + if (!@unlink($path) && file_exists($path)) { + $this->logError('Failed to delete file: ' . $path, $repoName, $userId); + throw new RuntimeException("Failed to delete file: {$path}"); + } + } + } + + clearstatcache(true, $directory); + + if (count(array_diff(scandir($directory), ['.', '..'])) !== 0) { + $this->logError('Directory not empty after attempting to delete: ' . $directory, $repoName, $userId); + throw new RuntimeException("Directory not empty after attempting to delete: {$directory}"); + } + + if (!@rmdir($directory) && is_dir($directory)) { + $this->logError('Failed to delete directory: ' . $directory, $repoName, $userId); + throw new RuntimeException("Failed to delete directory: {$directory}"); + } + } + + /** + * Resolve and check if the given path is a valid local directory. + * + * @param string $path + * @return string|null Returns the resolved absolute path if it exists, null otherwise + */ + protected function resolveLocalPath(string $path): ?string + { + // Check if it's already an absolute path + if (is_dir($path)) { + return realpath($path); + } + + // Try as relative path from current working directory + $resolved = realpath($path); + if ($resolved && is_dir($resolved)) { + return $resolved; + } + + // Try as relative path from base path + $basePath = base_path($path); + $resolved = realpath($basePath); + if ($resolved && is_dir($resolved)) { + return $resolved; + } + + return null; + } + + /** + * Prepare git URL with GITHUB_TOKEN if available. + * + * @param string $url + * @return string + */ + protected function prepareGitUrl(string $url): string + { + $token = env('GITHUB_TOKEN'); + + if ($token && str_starts_with($url, 'https://github.com/')) { + // Insert token into HTTPS URL + $url = str_replace('https://github.com/', "https://{$token}@github.com/", $url); + } + + return $url; + } + + /** + * Log a running status message. + * + * @param string $message + * @param string $pluginName + * @return void + */ + protected function logRunning(string $message, string $pluginName, ?int $userId = null): void + { + $this->log($message, 'running', $pluginName, $userId); + } + + /** + * Log an error status message. + * + * @param string $message + * @param string $pluginName + * @return void + */ + protected function logError(string $message, string $pluginName, ?int $userId = null): void + { + $this->log($message, 'error', $pluginName, $userId); + } + + /** + * Log a done status message. + * + * @param string $message + * @param string $pluginName + * @return void + */ + protected function logDone(string $message, string $pluginName, ?int $userId = null): void + { + $this->log($message, 'done', $pluginName, $userId); + } + + /** + * Log a plugin event. + * + * @param string $message + * @param string $type + * @param string $pluginName + * @return void + */ + protected function log(string $message, string $type, string $pluginName, ?int $userId = null): void + { + if ($type === 'error') { + \Log::error($message); + } else { + \Log::info($message); + } + + if ($userId) { + event(new PluginLog($message, $type, $pluginName, $userId)); + } + } +} diff --git a/ProcessMaker/Managers/ScreenBuilderManager.php b/ProcessMaker/Managers/ScreenBuilderManager.php index fb4e1d88e2..75b2f13f0c 100644 --- a/ProcessMaker/Managers/ScreenBuilderManager.php +++ b/ProcessMaker/Managers/ScreenBuilderManager.php @@ -52,8 +52,16 @@ public function getScripts() public function addPackageScripts($type = 'DISPLAY') { - // Depending of the form type we load the correct file with the components to display - $extensionsFile = 'screen-builder-' . strtolower($type) . '-components.js'; + $this->addComposerPackageScripts($type); + $this->addStoragePluginScripts($type); + } + + /** + * Load screen-builder extension scripts from Composer packages under vendor/processmaker/packages. + */ + private function addComposerPackageScripts(string $type): void + { + $extensionsFile = $this->screenBuilderExtensionsFile($type); $directories = glob('vendor/processmaker/packages/*', GLOB_ONLYDIR); $installed = app(PackageManifest::class)->list(); @@ -64,10 +72,96 @@ public function addPackageScripts($type = 'DISPLAY') })); foreach ($directories as $directory) { $extensionsFullName = $directory . '/js/' . $extensionsFile; - $files = glob($extensionsFullName); - if (count($files) > 0) { - $this->addScript('/' . $files[0]); + foreach (glob($extensionsFullName) ?: [] as $file) { + $publicPath = $this->normalizePublicScriptPath($file); + if ($publicPath !== null) { + $this->addScript($publicPath); + } } } } + + /** + * Load screen-builder extension scripts for enabled plugins in storage/plugins when + * assets were published to public/vendor/processmaker/packages/{name}/js/. + */ + private function addStoragePluginScripts(string $type): void + { + $extensionsFile = $this->screenBuilderExtensionsFile($type); + $pluginsFolder = storage_path('plugins'); + + if (!is_dir($pluginsFolder)) { + return; + } + + foreach (glob($pluginsFolder . '/*', GLOB_ONLYDIR) ?: [] as $pluginPath) { + $folderName = basename($pluginPath); + if (str_starts_with($folderName, '_')) { + continue; + } + + $packageName = $this->resolvePluginPackageName($pluginPath, $folderName); + $relativePath = '/vendor/processmaker/packages/' . $packageName . '/js/' . $extensionsFile; + + if (file_exists(public_path($relativePath))) { + $this->addScript($relativePath); + } + } + } + + private function screenBuilderExtensionsFile(string $type): string + { + return 'screen-builder-' . strtolower($type) . '-components.js'; + } + + /** + * Resolve the web package folder name (e.g. package-plg) from a plugin directory. + */ + private function resolvePluginPackageName(string $pluginPath, string $folderName): string + { + $composerPath = $pluginPath . '/composer.json'; + if (!is_file($composerPath)) { + return $folderName; + } + + $content = file_get_contents($composerPath); + $data = json_decode($content, true); + if (!is_array($data)) { + return $folderName; + } + + $name = $data['name'] ?? null; + if (is_string($name) && str_contains($name, '/')) { + return substr($name, strrpos($name, '/') + 1); + } + + return $folderName; + } + + /** + * Convert a filesystem path to a public-relative script path with a leading slash. + */ + private function normalizePublicScriptPath(string $filesystemPath): ?string + { + $path = str_replace('\\', '/', $filesystemPath); + if ($path === '') { + return null; + } + + if (str_starts_with($path, '/')) { + $publicRoot = str_replace('\\', '/', realpath(public_path()) ?: public_path()); + if (str_starts_with($path, $publicRoot)) { + return '/' . ltrim(substr($path, strlen($publicRoot)), '/'); + } + + $baseRoot = str_replace('\\', '/', realpath(base_path()) ?: base_path()); + if (str_starts_with($path, $baseRoot)) { + return '/' . ltrim(substr($path, strlen($baseRoot)), '/'); + } + + return null; + } + + return '/' . ltrim($path, '/'); + } } diff --git a/ProcessMaker/Models/Plugin.php b/ProcessMaker/Models/Plugin.php new file mode 100644 index 0000000000..adf5170a07 --- /dev/null +++ b/ProcessMaker/Models/Plugin.php @@ -0,0 +1,206 @@ +path = $path; + $plugin->name = basename($path); + + return $plugin; + } + + /** + * Get the plugin name. + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get the plugin path. + * + * @return string + */ + public function getPath(): string + { + return $this->path; + } + + /** + * Get the composer.json data. + * + * @return array + * @throws RuntimeException + */ + public function getComposerJson(): array + { + if ($this->composerJson !== null) { + return $this->composerJson; + } + + $composerJsonPath = $this->path . '/composer.json'; + + if (!file_exists($composerJsonPath)) { + throw new RuntimeException("composer.json not found in plugin: {$this->path}"); + } + + $content = file_get_contents($composerJsonPath); + $composerJson = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException( + "Invalid JSON in composer.json at {$composerJsonPath}: " . json_last_error_msg() + ); + } + + $this->composerJson = $composerJson; + + return $this->composerJson; + } + + /** + * Get the plugin description from composer.json. + * + * @return string|null + */ + public function getDescription(): ?string + { + try { + $composerJson = $this->getComposerJson(); + + return $composerJson['description'] ?? null; + } catch (RuntimeException $e) { + return null; + } + } + + /** + * Get the current git branch. + * + * @return string|null + */ + public function getBranch(): ?string + { + if (!is_dir($this->path . '/.git')) { + return null; + } + + try { + $process = Process::path($this->path) + ->run('git rev-parse --abbrev-ref HEAD'); + + if ($process->successful()) { + return trim($process->output()); + } + } catch (\Exception $e) { + // Ignore errors + } + + return null; + } + + /** + * Get the current git tag if on a tag. + * + * @return string|null + */ + public function getTag(): ?string + { + if (!is_dir($this->path . '/.git')) { + return null; + } + + try { + $process = Process::path($this->path) + ->run('git describe --exact-match --tags HEAD 2>/dev/null'); + + if ($process->successful()) { + return trim($process->output()); + } + } catch (\Exception $e) { + // Ignore errors + } + + return null; + } + + /** + * Get the current git reference (branch or tag). + * + * @return string|null + */ + public function getReference(): ?string + { + $tag = $this->getTag(); + if ($tag !== null) { + return $tag; + } + + return $this->getBranch(); + } + + /** + * Get the plugin URL. + * + * @return string + */ + public function getUrl(): string + { + return 'https://github.com/ProcessMaker/' . $this->name; + } + + /** + * Override getTable to return null since this model doesn't use a database table. + * + * @return string|null + */ + public function getTable() + { + return null; + } +} diff --git a/ProcessMaker/Models/Setting.php b/ProcessMaker/Models/Setting.php index 87395b44d8..2143e14a32 100644 --- a/ProcessMaker/Models/Setting.php +++ b/ProcessMaker/Models/Setting.php @@ -75,12 +75,12 @@ class Setting extends ProcessMakerModel implements HasMedia, PrometheusMetricInt public const SESSION_CONTROL_GROUP = 'Session Control'; + public const READY_KEY = 'readyToUseSettingsDatabase'; + private static bool $redisAvailable = false; private static bool $settingsTableExists = false; - private static bool $readyToUseSettingsDatabase = false; - /** * The attributes that aren't mass assignable. * @@ -535,15 +535,15 @@ public function getPrometheusMetricLabel(): string public static function readyToUseSettingsDatabase() { - if (!self::$readyToUseSettingsDatabase) { - self::$readyToUseSettingsDatabase = + if (!app()->has(self::READY_KEY) || !app(self::READY_KEY)) { + app()->instance(self::READY_KEY, app('tenant-resolved') && self::databaseAvailable() && self::redisAvailable() && - self::settingsTableExists(); + self::settingsTableExists()); } - return self::$readyToUseSettingsDatabase; + return app(self::READY_KEY); } private static function databaseAvailable() diff --git a/ProcessMaker/Multitenancy/SwitchTenant.php b/ProcessMaker/Multitenancy/SwitchTenant.php index cd35cd353c..3cdf169d3d 100644 --- a/ProcessMaker/Multitenancy/SwitchTenant.php +++ b/ProcessMaker/Multitenancy/SwitchTenant.php @@ -41,6 +41,17 @@ public function makeCurrent(IsTenant $tenant): void request()->headers->set('host', $tenant->domain); $this->overrideConfigs($app, $tenant); + + // Extend BroadcastManager to our custom implementation that prefixes the channel names with the tenant id. + $app->extend(BroadcastManager::class, function ($manager, $app) use ($tenant) { + return new TenantAwareBroadcastManager($app, $tenant->id); + }); + + // Configure the tenant-specific route cache before Laravel checks whether it + // exists. This lets `TENANT= php artisan route:cache` create or refresh + // the cache for a tenant after a plugin is installed, enabled, or disabled. + Env::getRepository()->set('APP_ROUTES_CACHE', storage_path('routes-v7.php')); + $this->reloadRouteCache($app); } /** @@ -52,6 +63,7 @@ public function forgetCurrent(): void { $app = app(); $app->useStoragePath(base_path('storage')); + Env::getRepository()->clear('APP_ROUTES_CACHE'); $this->setConfig('logging.channels.daily.path', storage_path('logs/processmaker.log')); $app->make('log')->reset(); @@ -105,6 +117,29 @@ private function setEnvironmentVariable($key, $value) $_ENV[$key] = $value; } + /** + * Reload the tenant's cached routes when the router can persist in memory. + * + * Octane keeps the router in memory between requests. The console and test + * environments may also initialize the router before a tenant is made + * current. Updating APP_ROUTES_CACHE alone is insufficient in those cases. + */ + private function reloadRouteCache(Application $app): void + { + if (!$app->routesAreCached() || !$this->shouldReinitializeRouter()) { + return; + } + + require $app->getCachedRoutesPath(); + } + + private function shouldReinitializeRouter(): bool + { + return isset($_SERVER['LARAVEL_OCTANE']) + || app()->runningInConsole() + || app()->runningUnitTests(); + } + private function overrideConfigs(Application $app, IsTenant $tenant) { $this->setEnvironmentVariable('APP_URL', $tenant->config['app.url']); diff --git a/ProcessMaker/Providers/PluginServiceProvider.php b/ProcessMaker/Providers/PluginServiceProvider.php new file mode 100644 index 0000000000..a98f71f5a2 --- /dev/null +++ b/ProcessMaker/Providers/PluginServiceProvider.php @@ -0,0 +1,130 @@ +loadPlugins(); + }); + } + + /** + * Load all plugins from the storage/plugins directory. + */ + protected function loadPlugins(): void + { + $pluginsFolder = storage_path('plugins'); + $pluginPaths = glob($pluginsFolder . '/*', GLOB_ONLYDIR); + + foreach ($pluginPaths as $pluginPath) { + // Ignore plugins that start with _ + if (str_starts_with(basename($pluginPath), '_')) { + continue; + } + $this->loadPlugin($pluginPath); + } + } + + /** + * Load a single plugin from the given path. + * + * @param string $pluginPath + */ + protected function loadPlugin(string $pluginPath): void + { + $composerJsonPath = $pluginPath . '/composer.json'; + + if (!file_exists($composerJsonPath)) { + return; + } + + $composerJson = $this->loadComposerJson($composerJsonPath); + $this->registerAutoloader($pluginPath, $composerJson); + $this->registerServiceProviders($composerJson); + } + + /** + * Load and decode composer.json file. + * + * @param string $composerJsonPath + * @return array + */ + protected function loadComposerJson(string $composerJsonPath): array + { + $content = file_get_contents($composerJsonPath); + $composerJson = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException( + "Invalid JSON in composer.json at {$composerJsonPath}: " . json_last_error_msg() + ); + } + + return $composerJson; + } + + /** + * Register PSR-4 autoloader for the plugin. + * + * @param string $pluginPath + * @param array $composerJson + */ + protected function registerAutoloader(string $pluginPath, array $composerJson): void + { + $autoloadPath = $pluginPath . '/vendor/autoload.php'; + + if (!file_exists($autoloadPath)) { + return; + } + + $loader = require_once $autoloadPath; + if ($loader === true) { + // Already loaded and presumably registered + return; + } + + $psr4Namespaces = Arr::get($composerJson, 'autoload.psr-4', []); + + foreach ($psr4Namespaces as $namespace => $path) { + $fullPath = $pluginPath . '/' . $path; + + if (!is_dir($fullPath)) { + throw new RuntimeException( + "Plugin {$pluginPath} has a PSR-4 path that does not exist: {$path}" + ); + } + + $loader->addPsr4($namespace, $fullPath); + } + } + + /** + * Register Laravel service providers defined in the plugin's composer.json. + * + * @param array $composerJson + */ + protected function registerServiceProviders(array $composerJson): void + { + $providers = Arr::get($composerJson, 'extra.laravel.providers', []); + + foreach ($providers as $provider) { + app()->register($provider); + } + } +} diff --git a/ai-docs/processmaker/requirements/components/plugin-manager/plugin-manager.md b/ai-docs/processmaker/requirements/components/plugin-manager/plugin-manager.md new file mode 100644 index 0000000000..83f5bbfe27 --- /dev/null +++ b/ai-docs/processmaker/requirements/components/plugin-manager/plugin-manager.md @@ -0,0 +1,31 @@ +# Plugin manager + +Lifecycle management for ProcessMaker plugins stored under the tenant plugins directory. + +## Purpose + +Allow authorized operators to install plugins from a Git repository URL or zip archive, uninstall them, enable/disable them, and observe progress without blocking the HTTP request. + +## Kind and reuse + +- `kind`: component +- Configurable via `config` in `requirements.yaml` (sources, progress channel, queue settings) +- Admin UI screens compose this component through `dependsOn` + +## Actors + +- Operator with `manage-plugins` permission +- Queue worker (Horizon) executing plugin jobs +- Runtime refresh after plugin code changes (route cache, worker restart) + +## Related assets + +- `ProcessMaker/Managers/PluginManager.php` +- `ProcessMaker/Jobs/InstallPlugin.php`, `UninstallPlugin.php`, `TogglePlugin.php` +- `ProcessMaker/Http/Controllers/Api/PluginController.php` +- `ProcessMaker/Events/PluginLog.php` +- `resources/js/admin/plugins/` + +## Configuration + +Consumers configure allowed install sources (`url`, `zip`), optional Git `branch`/`tag` (mutually exclusive), progress broadcast target (authenticated user channel), and job timeout / overlap policy for concurrent plugin operations. diff --git a/ai-docs/processmaker/requirements/components/plugin-manager/requirements.yaml b/ai-docs/processmaker/requirements/components/plugin-manager/requirements.yaml new file mode 100644 index 0000000000..c8b8290e34 --- /dev/null +++ b/ai-docs/processmaker/requirements/components/plugin-manager/requirements.yaml @@ -0,0 +1,243 @@ +specId: plugin-manager +kind: component +title: Plugin manager +description: >- + Install, uninstall, and toggle ProcessMaker plugins asynchronously with + progress events for the requesting operator. +config: + sources: + - url + - zip + gitRefOptions: + - branch + - tag + progressEvent: PluginLog + queue: + timeoutSeconds: 600 + withoutOverlapping: true + permission: manage-plugins +dependsOn: [] +requirements: + - id: FR-001 + requirementType: Functional + title: Queue plugin install from repository URL + statement: >- + When an authorized operator requests install from a repository URL, the + component must enqueue the install work and return immediately without + waiting for clone or dependency installation to finish. + priority: Critical + status: Draft + tags: [plugins, install, queue] + acceptanceCriteria: + - given: An operator with manage-plugins permission submits a valid repository URL + when: The install request is accepted + then: An install job is queued and the HTTP response indicates the work was queued + - given: An install job is queued with an optional branch or tag + when: The worker runs the job + then: The plugin is cloned or updated and the requested git reference is checked out before dependency install + traceability: + testCases: [] + + - id: FR-002 + requirementType: Functional + title: Queue plugin install from zip archive + statement: >- + When an authorized operator uploads a zip archive, the component must + persist the archive for the worker, enqueue install, and return without + blocking on extraction or dependency installation. + priority: High + status: Draft + tags: [plugins, install, zip, queue] + acceptanceCriteria: + - given: An operator uploads a valid plugin zip file + when: The install request is accepted + then: The zip is stored for the worker, an install job is queued, and the response indicates queued status + - given: A queued zip install job runs + when: Extraction and validation succeed + then: The plugin is copied into the plugins directory and dependencies are installed + traceability: + testCases: [] + + - id: FR-003 + requirementType: Functional + title: Queue plugin uninstall + statement: >- + When an authorized operator requests uninstall by plugin name, the + component must enqueue uninstallation and return without waiting for the + uninstall command or directory removal to finish. + priority: High + status: Draft + tags: [plugins, uninstall, queue] + acceptanceCriteria: + - given: An installed plugin exists by name + when: The operator requests uninstall + then: An uninstall job is queued and the response indicates queued status + - given: An uninstall job runs for a known plugin + when: The job completes successfully + then: The plugin directory is removed and a done progress event is emitted + traceability: + testCases: [] + + - id: FR-004 + requirementType: Functional + title: Queue plugin enable and disable toggle + statement: >- + When an authorized operator toggles a plugin, the component must enqueue + the enable or disable rename and return without waiting for runtime refresh. + priority: High + status: Draft + tags: [plugins, toggle, queue] + acceptanceCriteria: + - given: A plugin directory exists in enabled or disabled form + when: The operator requests toggle + then: A toggle job is queued and the response indicates queued status + - given: A toggle job disables an enabled plugin + when: The job completes + then: The plugin folder is renamed with a leading underscore and a done progress event is emitted + traceability: + testCases: [] + + - id: FR-005 + requirementType: Functional + title: Broadcast install progress to the requesting operator + statement: >- + During queued plugin operations the component must emit progress events + to the requesting operator so the client can show running, done, and error + states without polling the HTTP install endpoint. + priority: Critical + status: Draft + tags: [plugins, progress, broadcast] + acceptanceCriteria: + - given: A plugin job is running for a known user id + when: The manager logs a running step + then: A PluginLog progress event is broadcast on that user's private channel + - given: A plugin job fails with an exception + when: The failure is handled + then: An error progress event with a failure message is broadcast to the requesting user + traceability: + testCases: [] + + - id: FR-006 + requirementType: Functional + title: Validate plugin composer structure before install completes + statement: >- + The component must reject plugins whose composer.json is missing, invalid, + or whose PSR-4 namespace does not start with an allowed ProcessMaker plugin + or package prefix. + priority: High + status: Draft + tags: [plugins, validation] + acceptanceCriteria: + - given: A cloned or extracted plugin lacks a valid composer.json PSR-4 prefix + when: Validation runs during install + then: Install fails with an error progress event and the plugin is not marked installed successfully + - given: A plugin composer.json uses an allowed ProcessMaker Plugins or Package namespace + when: Validation runs + then: Validation succeeds and dependency installation may proceed + traceability: + testCases: [] + + - id: FR-007 + requirementType: Functional + title: Refresh runtime after plugin code changes + statement: >- + After a successful install, uninstall, or toggle, the component must + rebuild the route cache for the current tenant context and signal queue + workers to restart so new plugin code is loaded. + priority: High + status: Draft + tags: [plugins, runtime, cache] + acceptanceCriteria: + - given: A plugin install job finishes successfully + when: Runtime refresh runs + then: Route cache is rebuilt and queue workers are signaled to terminate after the current job + - given: Multitenancy is enabled and a tenant is active + when: Artisan subprocesses run for refresh + then: The tenant id is passed in the subprocess environment + traceability: + testCases: [] + + - id: FR-008 + requirementType: Functional + title: List installed plugins including disabled ones + statement: >- + The component must list plugins from the plugins directory, including + disabled folders, with name, description, version, reference, and enabled + state derived from folder naming. + priority: Medium + status: Draft + tags: [plugins, list] + acceptanceCriteria: + - given: Enabled and underscore-prefixed disabled plugin folders exist + when: The list operation runs + then: Both appear with enabled state reflecting the underscore prefix + - given: A folder under plugins is not a valid plugin + when: The list operation runs + then: The invalid folder is skipped without failing the whole list + traceability: + testCases: [] + + - id: NFR-001 + requirementType: NonFunctional + title: Install request returns within API timeout budget + statement: >- + The HTTP install, uninstall, and toggle endpoints must acknowledge the + queued operation quickly so clients are not blocked by git clone or + composer install duration. + priority: Critical + status: Draft + category: Performance + tags: [plugins, latency, queue] + metrics: + - name: queue_ack_latency + operator: "<=" + value: 5 + unit: seconds + verificationMethod: Test + acceptanceCriteria: + - given: A valid install request is submitted while the queue is available + when: The endpoint responds + then: The response is returned in at most five seconds without waiting for composer install + + - id: NFR-002 + requirementType: NonFunctional + title: Plugin jobs allow long-running dependency install + statement: >- + Queued plugin jobs must allow sufficient wall-clock time for git and + composer operations, and subprocess timeouts must not be shorter than the + job budget for those steps. + priority: Critical + status: Draft + category: Reliability + tags: [plugins, timeout, composer] + metrics: + - name: job_timeout + operator: ">=" + value: 600 + unit: seconds + verificationMethod: Inspection + acceptanceCriteria: + - given: A plugin install job is configured + when: Job timeout settings are inspected + then: The job timeout is at least 600 seconds and composer subprocesses are not capped at the default 60-second process timeout + + - id: NFR-003 + requirementType: NonFunctional + title: Serialize concurrent plugin mutations + statement: >- + Concurrent install, uninstall, or toggle jobs must not overlap mutating + the plugins directory for the same deployment context. + priority: High + status: Draft + category: Reliability + tags: [plugins, concurrency] + metrics: + - name: overlapping_plugin_mutations + operator: "=" + value: 0 + unit: count + verificationMethod: Test + acceptanceCriteria: + - given: A plugin install job is already running + when: Another plugin mutation job is dispatched + then: The second job waits or is deferred until the overlapping lock is released diff --git a/ai-docs/processmaker/requirements/screens/admin-plugins/admin-plugins.md b/ai-docs/processmaker/requirements/screens/admin-plugins/admin-plugins.md new file mode 100644 index 0000000000..d800f1475c --- /dev/null +++ b/ai-docs/processmaker/requirements/screens/admin-plugins/admin-plugins.md @@ -0,0 +1,26 @@ +# Admin plugins screen + +Administrative screen for listing and managing ProcessMaker plugins. + +## Purpose + +Give operators with `manage-plugins` a UI to list plugins, start install from a repository URL (branch or tag), uninstall, and toggle enable/disable, while showing live install output. + +## Kind and reuse + +- `kind`: screen +- Depends on `plugin-manager` for lifecycle behavior; does not redefine install/uninstall FRs +- Progress UI listens to `PluginLog` on the authenticated user's private channel + +## Related assets + +- `resources/views` admin plugins entry +- `resources/js/admin/plugins/Plugins.vue` +- `resources/js/admin/plugins/components/PluginOutput.vue` +- Web route `plugins.index`, API routes under `api/1.0/plugins*` + +## Assumptions + +- Horizon (or equivalent queue workers) is running so queued plugin jobs execute. +- Echo / broadcasting is available so `PluginLog` updates appear in `PluginOutput`. +- The screen does not wait for clone/composer to finish on the HTTP response; it stays open until a done or error progress event. diff --git a/ai-docs/processmaker/requirements/screens/admin-plugins/requirements.yaml b/ai-docs/processmaker/requirements/screens/admin-plugins/requirements.yaml new file mode 100644 index 0000000000..d4ae64df70 --- /dev/null +++ b/ai-docs/processmaker/requirements/screens/admin-plugins/requirements.yaml @@ -0,0 +1,129 @@ +specId: admin-plugins +kind: screen +title: Admin plugins screen +description: >- + Admin UI to list plugins and start queued install, uninstall, and toggle + operations with live PluginLog output. +config: + permission: manage-plugins + installFields: + - url + - branch + - tag + progressComponent: PluginOutput +dependsOn: + - plugin-manager +requirements: + - id: FR-001 + requirementType: Functional + title: Restrict screen to manage-plugins operators + statement: >- + The screen must only be available to authenticated operators who have the + manage-plugins permission. + priority: Critical + status: Draft + tags: [plugins, authz] + dependencies: + - plugin-manager#FR-001 + acceptanceCriteria: + - given: An authenticated operator without manage-plugins permission + when: The operator opens the admin plugins route + then: Access is denied + - given: An authenticated operator with manage-plugins permission + when: The operator opens the admin plugins route + then: The plugins list screen is shown + traceability: + testCases: [] + + - id: FR-002 + requirementType: Functional + title: List plugins in a searchable table + statement: >- + The screen must show installed plugins from the plugin-manager list, + including enabled state, and support client search and pagination. + priority: High + status: Draft + tags: [plugins, list, ui] + dependencies: + - plugin-manager#FR-008 + acceptanceCriteria: + - given: The plugin-manager list returns enabled and disabled plugins + when: The screen finishes loading + then: Each plugin appears with name and enabled state + - given: The operator enters a search filter + when: The filter is applied + then: The table shows only matching plugin rows + traceability: + testCases: [] + + - id: FR-003 + requirementType: Functional + title: Start queued install and keep modal open for progress + statement: >- + When the operator submits the install form, the screen must call the + install API, treat a queued acknowledgment as success for dispatch, keep + the modal open, and rely on PluginLog events for completion instead of + reloading on the HTTP response alone. + priority: Critical + status: Draft + tags: [plugins, install, ui, progress] + dependencies: + - plugin-manager#FR-001 + - plugin-manager#FR-005 + acceptanceCriteria: + - given: The operator fills a valid repository URL and submits install + when: The API returns a queued acknowledgment + then: The modal stays open and PluginOutput shows progress from PluginLog events + - given: A PluginLog done event arrives for a successful install + when: The screen handles the event + then: The operator is informed of success and may close or refresh the list + - given: A PluginLog error event arrives during install + when: The screen handles the event + then: The error is visible in the output panel and the list is not treated as successfully updated + traceability: + testCases: [] + + - id: FR-004 + requirementType: Functional + title: Trigger queued uninstall and toggle from row actions + statement: >- + Row actions must call uninstall and toggle APIs that enqueue work, then + refresh or reload after a successful completion signal rather than + assuming the HTTP acknowledgment finished the mutation. + priority: High + status: Draft + tags: [plugins, uninstall, toggle, ui] + dependencies: + - plugin-manager#FR-003 + - plugin-manager#FR-004 + acceptanceCriteria: + - given: The operator confirms delete on a plugin row + when: The uninstall API returns queued acknowledgment + then: The UI waits for completion feedback before treating the plugin as removed + - given: The operator clicks toggle on a plugin row + when: The toggle API returns queued acknowledgment + then: The UI waits for completion feedback before showing the new enabled state as final + traceability: + testCases: [] + + - id: NFR-001 + requirementType: NonFunctional + title: Install UI must not use the short default API timeout as completion + statement: >- + The install flow must not interpret the default short API client timeout + as install failure when the server has already queued the job; completion + is determined by PluginLog events. + priority: Critical + status: Draft + category: Usability + tags: [plugins, timeout, ui] + metrics: + - name: install_completion_signal + operator: "=" + value: PluginLog + unit: event + verificationMethod: Demonstration + acceptanceCriteria: + - given: Composer install takes longer than the default API client timeout + when: The operator has submitted install and the job was queued + then: The UI continues showing progress from PluginLog instead of marking install failed solely due to the HTTP client timeout diff --git a/config/app.php b/config/app.php index b3c5891f98..0ded39bd55 100644 --- a/config/app.php +++ b/config/app.php @@ -195,6 +195,7 @@ * ProcessMaker Service Providers */ ProcessMaker\Providers\ProcessMakerServiceProvider::class, + ProcessMaker\Providers\PluginServiceProvider::class, ProcessMaker\Providers\RecommendationsServiceProvider::class, ProcessMaker\Providers\AuthServiceProvider::class, ProcessMaker\Providers\EventServiceProvider::class, diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index 7ba4437677..db61608b14 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -114,6 +114,14 @@ class PermissionSeeder extends Seeder 'edit-signals', 'delete-signals', ], + 'Plugins' => [ + 'manage-plugins', + 'create-plugins', + 'view-plugins', + 'install-plugins', + 'uninstall-plugins', + 'toggle-plugins', + ], ]; public function run($seedUser = null) diff --git a/package.json b/package.json index ad33d2d451..cd14154a7b 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "stats": "STATS=1 NODE_OPTIONS=\"--max-old-space-size=8000\" mix", "vite:dev": "vite", "vite:build": "vite build", - "build:all": "npm run development && npm run vite:build" + "build:all": "npm run development && npm run vite:build", + "check:requirements": "node .agents/skills/manage-requirements/scripts/check-requirements.js", + "validate:requirements": "node .agents/skills/manage-requirements/scripts/validate-requirements.js" }, "devDependencies": { "@babel/eslint-parser": "^7.15.8", diff --git a/resources/js/admin/plugins/Plugins.vue b/resources/js/admin/plugins/Plugins.vue new file mode 100644 index 0000000000..6384efdcf9 --- /dev/null +++ b/resources/js/admin/plugins/Plugins.vue @@ -0,0 +1,821 @@ + + + + + diff --git a/resources/js/admin/plugins/components/PluginOutput.vue b/resources/js/admin/plugins/components/PluginOutput.vue new file mode 100644 index 0000000000..e268a50e6f --- /dev/null +++ b/resources/js/admin/plugins/components/PluginOutput.vue @@ -0,0 +1,146 @@ + + + + + \ No newline at end of file diff --git a/resources/js/admin/plugins/index.js b/resources/js/admin/plugins/index.js new file mode 100644 index 0000000000..ad6dec3376 --- /dev/null +++ b/resources/js/admin/plugins/index.js @@ -0,0 +1,6 @@ +import Plugins from "./Plugins"; + +new Vue({ + el: "#plugins", + components: { Plugins }, +}); diff --git a/resources/js/admin/plugins/output.js b/resources/js/admin/plugins/output.js new file mode 100644 index 0000000000..46a5a78e44 --- /dev/null +++ b/resources/js/admin/plugins/output.js @@ -0,0 +1,6 @@ +import PluginOutput from "./components/PluginOutput"; + +new Vue({ + el: "#plugin-output", + components: { PluginOutput }, +}); diff --git a/resources/js/components/shared/Modal.vue b/resources/js/components/shared/Modal.vue index 5e3b273977..ce20062f8d 100644 --- a/resources/js/components/shared/Modal.vue +++ b/resources/js/components/shared/Modal.vue @@ -14,6 +14,8 @@ :header-class="headerClass" :footer-class="footerClass" :content-class="contentClass" + :no-close-on-esc="noCloseOnEsc" + :hide-header-close="hideHeaderClose" :size="size" :ok-only="okOnly" no-close-on-backdrop @@ -134,7 +136,9 @@ "titleIcon", "headerClass", "footerClass", - "contentClass" + "contentClass", + "noCloseOnEsc", + "hideHeaderClose" ], data() { return { diff --git a/resources/lang/en.json b/resources/lang/en.json index 87674aa096..63e6131518 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -193,6 +193,7 @@ "Are you sure you want to delete the auth client": "Are you sure you want to delete the auth client", "Are you sure you want to delete the calc ?:": "Are you sure you want to delete the calc ?", "Are you sure you want to delete the environment variable {{ name }} ?": "Are you sure you want to delete the environment variable {{ name }} ?", + "Are you sure you want to delete the plugin {{ name }}?": "Are you sure you want to delete the plugin {{ name }}?", "Are you sure you want to delete the screen {{item}}? Deleting this asset will break any active tasks that are assigned.": "Are you sure you want to delete the screen {{item}}? Deleting this asset will break any active tasks that are assigned.", "Are you sure you want to delete the token ": "Are you sure you want to delete the token ", "Are you sure you want to delete the user": "Are you sure you want to delete the user", @@ -304,6 +305,8 @@ "BPMN Issues": "BPMN Issues", "BPMN validation issues. Request cannot be started.": "BPMN validation issues. Request cannot be started.", "BPMN": "BPMN", + "Branch": "Branch", + "Branch must be a valid branch name": "Branch must be a valid branch name", "Browse": "Browse", "Browser": "Browser", "Build Command Output": "Build Command Output", @@ -518,6 +521,7 @@ "Create new copies of bundle assets.": "Create new copies of bundle assets.", "Create new DevLink": "Create new DevLink", "Create Notifications": "Create Notifications", + "Create Plugin": "Create Plugin", "Create Process Categories": "Create Process Categories", "Create Process Category": "Create Process Category", "Create Process": "Create Process", @@ -635,6 +639,7 @@ "Delete Groups": "Delete Groups", "Delete Notifications": "Delete Notifications", "Delete Page": "Delete Page", + "Delete Plugin": "Delete Plugin", "Delete Process Categories": "Delete Process Categories", "Delete Record": "Delete Record", "Delete Screen Categories": "Delete Screen Categories", @@ -744,6 +749,7 @@ "Edit Option": "Edit Option", "Edit Page Title": "Edit Page Title", "Edit Page": "Edit Page", + "Edit Plugin": "Edit Plugin", "Edit Process Categories": "Edit Process Categories", "Edit Process Category": "Edit Process Category", "Edit Process": "Edit Process", @@ -1589,6 +1595,17 @@ "Please wait while the files are generated. The screen will be updated when finished.": "Please wait while the files are generated. The screen will be updated when finished.", "Please wait while your content is loaded": "Please wait while your content is loaded", "Please wait": "Please wait", + "Plugin": "Plugin", + "Plugins": "Plugins", + "Plugin Installation Output": "Plugin Installation Output", + "Plugin Uninstallation Output": "Plugin Uninstallation Output", + "Plugin Toggle Output": "Plugin Toggle Output", + "Uninstall Plugin": "Uninstall Plugin", + "Plugin successfully uninstalled. You can now close this window.": "Plugin successfully uninstalled. You can now close this window.", + "Plugin successfully updated. You can now close this window.": "Plugin successfully updated. You can now close this window.", + "Error uninstalling plugin. See output above.": "Error uninstalling plugin. See output above.", + "Error toggling plugin. See output above.": "Error toggling plugin. See output above.", + "Plugin Successfully Installed. You can now close this window. ": "Plugin Successfully Installed. You can now close this window. ", "PM Block Name": "PM Block Name", "PM Blocks": "PM Blocks", "Pm_block_category": "PM Block Category", @@ -1717,6 +1734,8 @@ "Repeat on": "Repeat on", "Report an issue": "Report an issue", "Repository Name": "Repository Name", + "Repository URL": "Repository URL", + "Repository URL must be a valid URL": "Repository URL must be a valid URL", "Request All": "Request All", "Request Canceled": "Request Canceled", "Request Commented": "Request Commented", @@ -2140,6 +2159,7 @@ "Table Prompt Suffix": "Table Prompt Suffix", "Table": "Table", "Tag": "Tag", + "Tag must be a valid tag name": "Tag must be a valid tag name", "Takes precedence over value set in data.": "Takes precedence over value set in data.", "Task :task_id could not be reassigned because the task settings prevent it from being reassigned": "Task :task_id could not be reassigned because the task settings prevent it from being reassigned", "Task {{- subject }} completed by {{- user }}": "Task {{- subject }} completed by {{- user }}", @@ -2280,6 +2300,8 @@ "The package is not installed": "The package is not installed", "The page you are looking for could not be found": "The page you are looking for could not be found", "The placeholder is what is shown in the field when no value is provided yet": "The placeholder is what is shown in the field when no value is provided yet", + "The plugin was toggled.": "The plugin was toggled.", + "The plugin was deleted.": "The plugin was deleted.", "The PM Block name must be unique.": "The PM Block name must be unique.", "The Process field is required.": "The Process field is required.", "The process name must be unique.": "The process name must be unique.", @@ -2413,6 +2435,7 @@ "Toggle Configuration": "Toggle Configuration", "Toggle Notifications": "Toggle Notifications", "Toggle Show Password": "Toggle Show Password", + "Toggle": "Toggle", "Toggle Style": "Toggle Style", "Token URL": "Token URL", "Token_id": "Token ID", @@ -2420,6 +2443,7 @@ "TokenCreated": "Token Created", "TokenDeleted": "Token Deleted", "Tokens": "Tokens", + "Toggle Plugin": "Toggle Plugin", "Too many login attempts. Please try again in :seconds seconds.": "Too many login attempts. Please try again in :seconds seconds.", "Tooltip Contains": "Tooltip Contains", "Tooltip": "Tooltip", diff --git a/resources/lang/es.json b/resources/lang/es.json index cb2e96816f..6e51100555 100644 --- a/resources/lang/es.json +++ b/resources/lang/es.json @@ -289,6 +289,8 @@ "BPMN Issues": "Problemas de BPMN", "BPMN validation issues. Request cannot be started.": "Problemas de validación de BPMN. No se puede iniciar la solicitud.", "BPMN": "BPMN", + "Branch": "Rama", + "Branch must be a valid branch name": "La rama debe ser una rama válida", "Browse": "Examinar", "Browser": "Navegador", "Build Command Output": "Generar salida de comando", @@ -494,6 +496,7 @@ "Create new copies of bundle assets.": "Crear nuevas copias de los activos del paquete.", "Create new DevLink": "Crear nuevo DevLink", "Create Notifications": "Crear notificaciones", + "Create Plugin": "Crear plugin", "Create Process Categories": "Crear categorías de procesos", "Create Process Category": "Crear categoría de procesos", "Create Process": "Crear proceso", @@ -610,6 +613,7 @@ "Delete Groups": "Eliminar grupos", "Delete Notifications": "Eliminar notificaciones", "Delete Page": "Eliminar página", + "Delete Plugin": "Eliminar plugin", "Delete Process Categories": "Borrar las categorías de proceso", "Delete Record": "Eliminar registro", "Delete Screen Categories": "Eliminar categorías de pantalla", @@ -715,6 +719,7 @@ "Edit Option": "Editar opción", "Edit Page Title": "Editar título de página", "Edit Page": "Editar página", + "Edit Plugin": "Editar plugin", "Edit Process Categories": "Editar categorías de procesos", "Edit Process Category": "Editar categoría de procesos", "Edit Process": "Editar proceso", @@ -1527,6 +1532,10 @@ "Please wait while the files are generated. The screen will be updated when finished.": "Espere mientras se generan los archivos. La pantalla se actualizará cuando termine.", "Please wait while your content is loaded": "Espere mientras se carga su contenido", "Please wait": "Por favor espera", + "Plugin": "Plugin", + "Plugins": "Plugins", + "Plugin Installation Output": "Resultado de la instalación de plugin", + "Plugin Successfully Installed. You can now close this window. ": "Plugin instalado correctamente. Puede cerrar esta ventana.", "PM Block Name": "Nombre del Bloque PM", "PM Blocks": "Bloques PM", "Pm_block_category": "Categoría de Bloque PM", @@ -1652,6 +1661,8 @@ "Repeat on": "Repetir en", "Report an issue": "Reportar un problema", "Repository Name": "Nombre del Repositorio", + "Repository URL": "URL del Repositorio", + "Repository URL must be a valid URL": "La URL del repositorio debe ser una URL válida", "Request All": "Todas las solicitudes", "Request Canceled": "Solicitud cancelada", "Request Commented": "Solicitud Comentada", @@ -2068,6 +2079,7 @@ "Table": "Tabla", "Table Prompt Suffix": "Sufijo del Mensaje de Tabla", "Tag": "Etiqueta", + "Tag must be a valid tag name": "La etiqueta debe ser una etiqueta válida", "take care of things automatically.": "se encargan de las cosas automáticamente.", "Takes precedence over value set in data.": "Tiene prioridad sobre el conjunto de valores en los datos.", "Task :task_id could not be reassigned because the task settings prevent it from being reassigned": "La tarea :task_id no pudo ser reasignada porque la configuración de la tarea lo impide", @@ -2204,6 +2216,8 @@ "The package is not installed": "El paquete no esta instalado", "The page you are looking for could not be found": "No se pudo encontrar la página que está buscando", "The placeholder is what is shown in the field when no value is provided yet": "El marcador de posición es lo que se muestra en el campo cuando aún no se proporciona ningún valor", + "The plugin was toggled.": "El plugin fue activado/desactivado.", + "The plugin was deleted.": "El plugin fue eliminado.", "The PM Block name must be unique.": "El nombre del bloque PM debe ser único.", "The Process field is required.": "El campo Proceso es obligatorio.", "The process name must be unique.": "El nombre del proceso debe ser único.", @@ -2342,6 +2356,8 @@ "TokenCreated": "Token Creado", "TokenDeleted": "Token Eliminado", "Tokens": "Tokens", + "Toggle": "Alternar", + "Toggle Plugin": "Activar/desactivar plugin", "Too many login attempts. Please try again in :seconds seconds.": "Se han realizado demasiados intentos de inicio de sesión. Inténtelo de nuevo en: segundos segundos.", "Tooltip Contains": "La descripción emergente contiene", "Tooltip": "Descripción emergente", diff --git a/resources/views/admin/plugins/index.blade.php b/resources/views/admin/plugins/index.blade.php new file mode 100644 index 0000000000..775beff3c0 --- /dev/null +++ b/resources/views/admin/plugins/index.blade.php @@ -0,0 +1,31 @@ +@extends('layouts.layoutnextvite') + +@section('title') + {{__('Plugins')}} +@endsection + +@section('sidebar') + @include('layouts.sidebar', ['sidebar'=> Menu::get('sidebar_admin')]) +@endsection + +@section('breadcrumbs') + @include('shared.breadcrumbs', ['routes' => [ + __('Admin') => route('admin.index'), + __('Plugins') => null, + ]]) +@endsection +@section('content') +
+ +
+@endsection + +@section('js') + @vite('resources/js/loader/loaderMinimal.js') + + @vite(['resources/js/admin/plugins/index.js']) +@endsection \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index a94349b9b8..656a28d2f5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -22,6 +22,7 @@ use ProcessMaker\Http\Controllers\Api\NotificationController; use ProcessMaker\Http\Controllers\Api\OpenAIController; use ProcessMaker\Http\Controllers\Api\PermissionController; +use ProcessMaker\Http\Controllers\Api\PluginController; use ProcessMaker\Http\Controllers\Api\ProcessCategoryController; use ProcessMaker\Http\Controllers\Api\ProcessController; use ProcessMaker\Http\Controllers\Api\ProcessLaunchpadController; @@ -47,6 +48,12 @@ use ProcessMaker\Http\Controllers\TestStatusController; Route::middleware('auth:api', 'setlocale', 'bindings', 'sanitize', 'manager')->prefix('api/1.0')->name('api.')->group(function () { + // Plugins + Route::get('plugins', [PluginController::class, 'index'])->name('plugins.index')->middleware('can:manage-plugins'); + Route::post('plugins/install', [PluginController::class, 'install'])->name('plugins.install')->middleware('can:manage-plugins'); + Route::delete('plugins/{name}', [PluginController::class, 'destroy'])->name('plugins.destroy')->middleware('can:manage-plugins'); + Route::patch('plugins/{name}/toggle', [PluginController::class, 'toggle'])->name('plugins.toggle')->middleware('can:manage-plugins'); + // Users Route::get('users', [UserController::class, 'index'])->name('users.index'); // Permissions handled in the controller Route::get('users/{user}', [UserController::class, 'show'])->name('users.show'); // Permissions handled in the controller diff --git a/routes/web.php b/routes/web.php index ac7a916f8a..04a5a9e6e9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use ProcessMaker\Http\Controllers\Admin\GroupController; use ProcessMaker\Http\Controllers\Admin\LdapLogsController; use ProcessMaker\Http\Controllers\Admin\LogsController; +use ProcessMaker\Http\Controllers\Admin\PluginController; use ProcessMaker\Http\Controllers\Admin\QueuesController; use ProcessMaker\Http\Controllers\Admin\ScriptExecutorController; use ProcessMaker\Http\Controllers\Admin\SettingsController; @@ -78,6 +79,8 @@ Route::get('script-executors', [ScriptExecutorController::class, 'index'])->name('script-executors.index'); + Route::get('plugins', [PluginController::class, 'index'])->name('plugins.index')->middleware('can:manage-plugins'); + // Tenant Jobs Dashboard Route::get('tenant-queues', [TenantQueueController::class, 'index'])->name('tenant-queue.index'); diff --git a/tests/Feature/Api/PluginsTest.php b/tests/Feature/Api/PluginsTest.php new file mode 100644 index 0000000000..51c922d784 --- /dev/null +++ b/tests/Feature/Api/PluginsTest.php @@ -0,0 +1,84 @@ +apiCall('POST', '/plugins/install', [ + 'url' => 'https://github.com/ProcessMaker/package-translations.git', + 'branch' => 'develop', + ]); + + $response->assertStatus(200); + $response->assertJson([ + 'queued' => true, + 'message' => 'Plugin installation queued', + ]); + + Bus::assertDispatched(InstallPlugin::class, function (InstallPlugin $job) { + return $job->userId === $this->user->id + && $job->url === 'https://github.com/ProcessMaker/package-translations.git' + && $job->branch === 'develop' + && $job->tag === null + && $job->zipPath === null + && $job->timeout >= 600; + }); + } + + public function testUninstallQueuesJob(): void + { + Bus::fake([UninstallPlugin::class]); + + $response = $this->apiCall('DELETE', '/plugins/package-translations'); + + $response->assertStatus(200); + $response->assertJson(['queued' => true]); + + Bus::assertDispatched(UninstallPlugin::class, function (UninstallPlugin $job) { + return $job->pluginName === 'package-translations' + && $job->userId === $this->user->id; + }); + } + + public function testToggleQueuesJob(): void + { + Bus::fake([TogglePlugin::class]); + + $response = $this->apiCall('PATCH', '/plugins/package-translations/toggle'); + + $response->assertStatus(200); + $response->assertJson(['queued' => true]); + + Bus::assertDispatched(TogglePlugin::class, function (TogglePlugin $job) { + return $job->pluginName === 'package-translations' + && $job->userId === $this->user->id; + }); + } + + public function testInstallRejectsBranchAndTagTogether(): void + { + Bus::fake([InstallPlugin::class]); + + $response = $this->apiCall('POST', '/plugins/install', [ + 'url' => 'https://github.com/ProcessMaker/package-translations.git', + 'branch' => 'develop', + 'tag' => 'v1.0.0', + ]); + + $response->assertStatus(422); + Bus::assertNotDispatched(InstallPlugin::class); + } +} diff --git a/tests/Jobs/InstallPluginTest.php b/tests/Jobs/InstallPluginTest.php new file mode 100644 index 0000000000..024296eb51 --- /dev/null +++ b/tests/Jobs/InstallPluginTest.php @@ -0,0 +1,78 @@ +shouldReceive('install') + ->once() + ->with( + 'https://github.com/ProcessMaker/package-translations.git', + 'develop', + null, + 7 + ); + + $job = new InstallPlugin( + userId: 7, + url: 'https://github.com/ProcessMaker/package-translations.git', + branch: 'develop', + ); + $job->handle($manager); + + Event::assertDispatched(PluginLog::class, function (PluginLog $event) { + return $event->status === 'starting' + && $event->userId === 7; + }); + } + + public function testHandleInstallsFromZip(): void + { + Event::fake([PluginLog::class]); + + $zipPath = storage_path('app/plugin-uploads/example.zip'); + $manager = Mockery::mock(PluginManager::class); + $manager->shouldReceive('installFromZip') + ->once() + ->with($zipPath, 7); + + $job = new InstallPlugin( + userId: 7, + zipPath: $zipPath, + ); + $job->handle($manager); + + Event::assertDispatched(PluginLog::class, function (PluginLog $event) { + return $event->status === 'starting'; + }); + } + + public function testFailedBroadcastsError(): void + { + Event::fake([PluginLog::class]); + + $job = new InstallPlugin( + userId: 7, + url: 'https://github.com/ProcessMaker/package-translations.git', + ); + $job->failed(new \RuntimeException('composer failed')); + + Event::assertDispatched(PluginLog::class, function (PluginLog $event) { + return $event->status === 'error' + && str_contains($event->message, 'composer failed') + && $event->userId === 7; + }); + } +} diff --git a/tests/Managers/PluginManagerTest.php b/tests/Managers/PluginManagerTest.php new file mode 100644 index 0000000000..64e74c20a4 --- /dev/null +++ b/tests/Managers/PluginManagerTest.php @@ -0,0 +1,475 @@ +pluginsDir = storage_path('plugins'); + if (!is_dir($this->pluginsDir)) { + mkdir($this->pluginsDir, 0755, true); + } + } + + public function tearDownTestPluginManager() + { + if ($this->tempPluginDir && is_dir($this->tempPluginDir)) { + $this->deleteDirectory($this->tempPluginDir); + } + } + + public function testInstallExistingRepositoryChecksOutRequestedBranchWithoutMain() + { + $this->setUpTestPluginManager(); + + $repoUrl = 'https://github.com/ProcessMaker/plugin-develop.git'; + $pluginName = 'plugin-develop'; + $pluginPath = $this->pluginsDir . '/' . $pluginName; + + $this->tempPluginDir = $pluginPath; + mkdir($this->tempPluginDir, 0755, true); + file_put_contents( + $this->tempPluginDir . '/composer.json', + json_encode([ + 'name' => 'processmaker/plugin-develop', + 'autoload' => [ + 'psr-4' => [ + 'ProcessMaker\\Plugins\\Develop\\' => 'src/', + ], + ], + ], JSON_PRETTY_PRINT) + ); + + Process::fake([ + '*' => Process::result('', exitCode: 0), + ]); + + Event::fake([PluginLog::class]); + + $manager = new PluginManager(); + $manager->install($repoUrl, 'develop', null, 1); + + Process::assertRan(fn ($process) => $this->processRunsGitCommand($process, ['git', 'fetch', '--all', '--tags'])); + Process::assertRan(fn ($process) => $this->processRunsGitCommand($process, ['git', 'checkout', 'develop'])); + Process::assertRan(fn ($process) => $this->processRunsGitCommand($process, ['git', 'pull', 'origin', 'develop'])); + Process::assertNotRan(fn ($process) => $this->processRunsGitCommand($process, ['git', 'checkout', 'main'])); + } + + public function testInstallExistingRepositoryWithTagSkipsBranchCheckoutAndPull() + { + $this->setUpTestPluginManager(); + + $repoUrl = 'https://github.com/ProcessMaker/plugin-tagged.git'; + $pluginName = 'plugin-tagged'; + $pluginPath = $this->pluginsDir . '/' . $pluginName; + + $this->tempPluginDir = $pluginPath; + mkdir($this->tempPluginDir, 0755, true); + file_put_contents( + $this->tempPluginDir . '/composer.json', + json_encode([ + 'name' => 'processmaker/plugin-tagged', + 'autoload' => [ + 'psr-4' => [ + 'ProcessMaker\\Plugins\\Tagged\\' => 'src/', + ], + ], + ], JSON_PRETTY_PRINT) + ); + + Process::fake([ + '*' => Process::result('', exitCode: 0), + ]); + + Event::fake([PluginLog::class]); + + $manager = new PluginManager(); + $manager->install($repoUrl, null, 'v1.0.0', 1); + + Process::assertRan(fn ($process) => $this->processRunsGitCommand($process, ['git', 'checkout', 'tags/v1.0.0'])); + Process::assertNotRan(fn ($process) => $this->processRunsGitCommand($process, ['git', 'checkout', 'main'])); + Process::assertNotRan(fn ($process) => $this->processRunsGitCommand($process, ['git', 'pull'])); + } + + public function testInstallClonesRepositoryAndValidatesPlugin() + { + $this->setUpTestPluginManager(); + + $repoUrl = 'https://github.com/ProcessMaker/plugin-example.git'; + $pluginName = 'plugin-example'; + $pluginPath = $this->pluginsDir . '/' . $pluginName; + + // Create temporary plugin directory with valid composer.json + $this->tempPluginDir = $pluginPath; + mkdir($this->tempPluginDir, 0755, true); + $composerJson = [ + 'name' => 'processmaker/plugin-example', + 'description' => 'Test plugin', + 'autoload' => [ + 'psr-4' => [ + 'ProcessMaker\\Plugins\\Example\\' => 'src/', + ], + ], + ]; + file_put_contents( + $this->tempPluginDir . '/composer.json', + json_encode($composerJson, JSON_PRETTY_PRINT) + ); + + // Fake Process to return successful results + Process::fake([ + '*' => Process::result('', exitCode: 0), + ]); + + Event::fake([PluginLog::class]); + + $manager = new PluginManager(); + $manager->install($repoUrl, null, null, 1); + + Event::assertDispatched(PluginLog::class, function ($event) { + return str_contains($event->message, 'installed successfully'); + }); + $this->assertProcessRanCommand('horizon:terminate'); + } + + public function testInstallValidatesComposerJsonNamespace() + { + $this->setUpTestPluginManager(); + + $repoUrl = 'https://github.com/ProcessMaker/invalid-plugin.git'; + $pluginName = 'invalid-plugin'; + $pluginPath = $this->pluginsDir . '/' . $pluginName; + + // Create temporary plugin directory with invalid composer.json + $this->tempPluginDir = $pluginPath; + mkdir($this->tempPluginDir, 0755, true); + $composerJson = [ + 'name' => 'invalid/plugin', + 'autoload' => [ + 'psr-4' => [ + 'Invalid\\Namespace\\' => 'src/', + ], + ], + ]; + file_put_contents( + $this->tempPluginDir . '/composer.json', + json_encode($composerJson, JSON_PRETTY_PRINT) + ); + + // Fake Process to return successful results + Process::fake([ + '*' => Process::result('', exitCode: 0), + ]); + + Event::fake([PluginLog::class]); + + $manager = new PluginManager(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Plugin PSR-4 namespace must start with 'ProcessMaker\\Plugins\\'"); + + $manager->install($repoUrl); + } + + public function testUninstallRemovesPluginDirectory() + { + $this->setUpTestPluginManager(); + + $pluginName = 'test-plugin'; + $pluginPath = $this->pluginsDir . '/' . $pluginName; + + // Create temporary plugin directory + $this->tempPluginDir = $pluginPath; + mkdir($this->tempPluginDir, 0755, true); + file_put_contents($this->tempPluginDir . '/test.txt', 'test'); + + Event::fake([PluginLog::class]); + + Process::fake([ + '*' => Process::result('', exitCode: 0), + ]); + + $manager = new PluginManager(); + $manager->uninstall($pluginName, 1); + + $this->assertFalse(is_dir($pluginPath)); + Event::assertDispatched(PluginLog::class, function ($event) { + return str_contains($event->message, 'uninstalled successfully'); + }); + $this->assertProcessRanCommand('horizon:terminate'); + } + + public function testUninstallThrowsExceptionForNonExistentPlugin() + { + $this->setUpTestPluginManager(); + + $manager = new PluginManager(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Plugin not found: non-existent-plugin'); + + $manager->uninstall('non-existent-plugin'); + } + + public function testListReturnsInstalledPlugins() + { + $this->setUpTestPluginManager(); + + // Create test plugin directories + $plugin1Path = $this->pluginsDir . '/plugin-one'; + $plugin2Path = $this->pluginsDir . '/plugin-two'; + $this->tempPluginDir = $this->pluginsDir; + + mkdir($plugin1Path, 0755, true); + mkdir($plugin2Path, 0755, true); + + $composerJson1 = [ + 'name' => 'test/plugin-one', + 'description' => 'First test plugin', + 'autoload' => [ + 'psr-4' => [ + 'ProcessMaker\\Plugins\\One\\' => 'src/', + ], + ], + ]; + + $composerJson2 = [ + 'name' => 'test/plugin-two', + 'description' => 'Second test plugin', + 'autoload' => [ + 'psr-4' => [ + 'ProcessMaker\\Plugins\\Two\\' => 'src/', + ], + ], + ]; + + file_put_contents( + $plugin1Path . '/composer.json', + json_encode($composerJson1, JSON_PRETTY_PRINT) + ); + file_put_contents( + $plugin2Path . '/composer.json', + json_encode($composerJson2, JSON_PRETTY_PRINT) + ); + + // Mock git commands to return null (no git repo) + Process::fake([ + '*' => Process::result(''), + ]); + + $manager = new PluginManager(); + $plugins = $manager->list(); + + $this->assertCount(2, $plugins); + $this->assertEquals('plugin-one', $plugins[0]['name']); + $this->assertEquals('First test plugin', $plugins[0]['description']); + $this->assertEquals('plugin-two', $plugins[1]['name']); + $this->assertEquals('Second test plugin', $plugins[1]['description']); + } + + public function testListIncludesDisabledPlugins() + { + $this->setUpTestPluginManager(); + + $validPluginPath = $this->pluginsDir . '/valid-plugin'; + $hiddenPluginPath = $this->pluginsDir . '/_hidden-plugin'; + $this->tempPluginDir = $this->pluginsDir; + + mkdir($validPluginPath, 0755, true); + mkdir($hiddenPluginPath, 0755, true); + + $composerJson = [ + 'name' => 'test/plugin', + 'description' => 'Test plugin', + 'autoload' => [ + 'psr-4' => [ + 'ProcessMaker\\Plugins\\Test\\' => 'src/', + ], + ], + ]; + + file_put_contents( + $validPluginPath . '/composer.json', + json_encode($composerJson, JSON_PRETTY_PRINT) + ); + file_put_contents( + $hiddenPluginPath . '/composer.json', + json_encode($composerJson, JSON_PRETTY_PRINT) + ); + + Process::fake(); + + $manager = new PluginManager(); + $plugins = $manager->list(); + $pluginsByName = collect($plugins)->keyBy('name'); + + $this->assertCount(2, $plugins); + $this->assertEquals('Enabled', $pluginsByName['valid-plugin']['enabled']); + $this->assertEquals('Disabled', $pluginsByName['hidden-plugin']['enabled']); + } + + public function testArtisanEnvironmentIncludesCurrentTenant() + { + config(['app.multitenancy' => true]); + $this->app->instance('currentTenant', (object) ['id' => 7]); + + $method = new \ReflectionMethod(PluginManager::class, 'artisanEnvironment'); + $environment = $method->invoke(new PluginManager()); + + $this->assertSame(['TENANT' => '7'], $environment); + } + + public function testToggleRebuildsRouteCacheAndTerminatesHorizon() + { + $this->setUpTestPluginManager(); + + $pluginName = 'toggle-plugin'; + $pluginPath = $this->pluginsDir . '/' . $pluginName; + $this->tempPluginDir = $this->pluginsDir . '/_' . $pluginName; + mkdir($pluginPath, 0755, true); + + Process::fake([ + '*' => Process::result('', exitCode: 0), + ]); + + $manager = new PluginManager(); + $enabled = $manager->toggle($pluginName); + + $this->assertFalse($enabled); + $this->assertFalse(is_dir($pluginPath)); + $this->assertTrue(is_dir($this->pluginsDir . '/_' . $pluginName)); + + $this->assertProcessRanCommand('route:cache'); + $this->assertProcessRanCommand('horizon:terminate'); + } + + public function testToggleEnableTerminatesHorizon() + { + $this->setUpTestPluginManager(); + + $pluginName = 'toggle-plugin'; + $disabledPath = $this->pluginsDir . '/_' . $pluginName; + $this->tempPluginDir = $this->pluginsDir . '/' . $pluginName; + mkdir($disabledPath, 0755, true); + + Process::fake([ + '*' => Process::result('', exitCode: 0), + ]); + + $manager = new PluginManager(); + $enabled = $manager->toggle($pluginName); + + $this->assertTrue($enabled); + $this->assertTrue(is_dir($this->pluginsDir . '/' . $pluginName)); + $this->assertProcessRanCommand('horizon:terminate'); + } + + public function testLocalInstallReplacesLeftoverSymlinkWithCopy(): void + { + $this->setUpTestPluginManager(); + + $pluginName = 'local-symlink-plugin-' . uniqid(); + $sourceDir = sys_get_temp_dir() . '/' . $pluginName; + $pluginPath = $this->pluginsDir . '/' . $pluginName; + + mkdir($sourceDir, 0755, true); + mkdir($sourceDir . '/src', 0755, true); + file_put_contents($sourceDir . '/composer.json', json_encode([ + 'name' => 'processmaker/' . $pluginName, + 'autoload' => [ + 'psr-4' => [ + 'ProcessMaker\\Plugins\\SymlinkTest\\' => 'src/', + ], + ], + ], JSON_PRETTY_PRINT)); + file_put_contents($sourceDir . '/source-only.marker', 'source'); + + $this->assertTrue(symlink($sourceDir, $pluginPath)); + $this->assertTrue(is_link($pluginPath)); + + Process::fake([ + '*' => Process::result('', exitCode: 0), + ]); + Event::fake([PluginLog::class]); + + $manager = new PluginManager(); + $manager->install($sourceDir, null, null, 1); + + $this->assertFalse(is_link($pluginPath)); + $this->assertTrue(is_dir($pluginPath)); + $this->assertFileExists($pluginPath . '/source-only.marker'); + + if (is_dir($pluginPath)) { + $this->deleteDirectory($pluginPath); + } + if (is_dir($sourceDir)) { + $this->deleteDirectory($sourceDir); + } + } + + public function testRunCommandLogsSuccessfulOutputOnce() + { + Process::fake([ + '*' => Process::result("Routes cached successfully.\n", exitCode: 0), + ]); + Event::fake([PluginLog::class]); + + $method = new \ReflectionMethod(PluginManager::class, 'runCommand'); + $method->invoke(new PluginManager(), 'route:cache', 'test-plugin', 1); + + Event::assertDispatchedTimes(PluginLog::class, 1); + Event::assertDispatched(PluginLog::class, function (PluginLog $event) { + return str_contains($event->message, 'Plugin route:cache command output'); + }); + } + + private function assertProcessRanCommand(string $artisanCommand): void + { + Process::assertRan(function ($process) use ($artisanCommand) { + $command = is_array($process->command) + ? implode(' ', $process->command) + : (string) $process->command; + + return str_contains($command, $artisanCommand); + }); + } + + private function processRunsGitCommand($process, array $expectedCommand): bool + { + if (!is_array($process->command)) { + return false; + } + + return $process->command === $expectedCommand; + } + + private function deleteDirectory($dir) + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + if (is_dir($path)) { + $this->deleteDirectory($path); + } else { + unlink($path); + } + } + rmdir($dir); + } +} diff --git a/tests/Managers/ScreenBuilderManagerTest.php b/tests/Managers/ScreenBuilderManagerTest.php new file mode 100644 index 0000000000..2be23eaea7 --- /dev/null +++ b/tests/Managers/ScreenBuilderManagerTest.php @@ -0,0 +1,161 @@ + */ + private array $pluginDirsToRemove = []; + + /** @var list */ + private array $publicAssetPaths = []; + + protected function setUp(): void + { + chdir(realpath(__DIR__ . '/../..')); + parent::setUp(); + + $this->pluginsDir = storage_path('plugins'); + if (!is_dir($this->pluginsDir)) { + mkdir($this->pluginsDir, 0755, true); + } + } + + protected function tearDown(): void + { + foreach ($this->pluginDirsToRemove as $dir) { + if (is_dir($dir)) { + $this->deleteDirectory($dir); + } + } + foreach ($this->publicAssetPaths as $path) { + if (is_file($path)) { + unlink($path); + } + } + + chdir(realpath(__DIR__ . '/../..')); + + parent::tearDown(); + } + + public function testStoragePluginScreenBuilderScripts(): void + { + $enabledPackage = 'test-plg-plugin-' . uniqid(); + $enabledDir = $this->pluginsDir . '/' . $enabledPackage; + $this->pluginDirsToRemove[] = $enabledDir; + mkdir($enabledDir, 0755, true); + file_put_contents($enabledDir . '/composer.json', json_encode([ + 'name' => 'processmaker/' . $enabledPackage, + ])); + $this->touchPublicAsset($enabledPackage, 'screen-builder-form-components.js'); + + $manager = new ScreenBuilderManager(); + $manager->addPackageScripts('FORM'); + $scripts = $manager->getScripts(); + $this->assertTrue($this->scriptsContainPath( + $scripts, + '/vendor/processmaker/packages/' . $enabledPackage . '/js/screen-builder-form-components.js' + )); + + $disabledPackage = 'disabled-plg-plugin-' . uniqid(); + $disabledDir = $this->pluginsDir . '/_' . $disabledPackage; + $this->pluginDirsToRemove[] = $disabledDir; + mkdir($disabledDir, 0755, true); + file_put_contents($disabledDir . '/composer.json', json_encode([ + 'name' => 'processmaker/' . $disabledPackage, + ])); + $this->touchPublicAsset($disabledPackage, 'screen-builder-form-components.js'); + + $managerDisabled = new ScreenBuilderManager(); + $managerDisabled->addPackageScripts('FORM'); + $disabledScripts = $managerDisabled->getScripts(); + $this->assertFalse($this->scriptsContainPath( + $disabledScripts, + '/vendor/processmaker/packages/' . $disabledPackage . '/js/screen-builder-form-components.js' + )); + + $displayOnlyPackage = 'test-plg-display-only-' . uniqid(); + $displayDir = $this->pluginsDir . '/' . $displayOnlyPackage; + $this->pluginDirsToRemove[] = $displayDir; + mkdir($displayDir, 0755, true); + file_put_contents($displayDir . '/composer.json', json_encode([ + 'name' => 'processmaker/' . $displayOnlyPackage, + ])); + $this->touchPublicAsset($displayOnlyPackage, 'screen-builder-form-components.js'); + + $managerDisplay = new ScreenBuilderManager(); + $managerDisplay->addPackageScripts('DISPLAY'); + $displayScripts = $managerDisplay->getScripts(); + $this->assertFalse($this->scriptsContainPath( + $displayScripts, + '/vendor/processmaker/packages/' . $displayOnlyPackage . '/js/screen-builder-form-components.js' + )); + + $folderName = 'my-plugin-folder-' . uniqid(); + $resolvedName = 'resolved-plg-name-' . uniqid(); + $folderDir = $this->pluginsDir . '/' . $folderName; + $this->pluginDirsToRemove[] = $folderDir; + mkdir($folderDir, 0755, true); + file_put_contents($folderDir . '/composer.json', json_encode([ + 'name' => 'processmaker/' . $resolvedName, + ])); + $this->touchPublicAsset($resolvedName, 'screen-builder-form-components.js'); + + $managerResolved = new ScreenBuilderManager(); + $managerResolved->addPackageScripts('FORM'); + $resolvedScripts = $managerResolved->getScripts(); + $this->assertTrue($this->scriptsContainPath( + $resolvedScripts, + '/vendor/processmaker/packages/' . $resolvedName . '/js/screen-builder-form-components.js' + )); + } + + private function touchPublicAsset(string $packageName, string $fileName): string + { + $dir = public_path('vendor/processmaker/packages/' . $packageName . '/js'); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + $path = $dir . '/' . $fileName; + file_put_contents($path, '// test asset'); + $this->publicAssetPaths[] = $path; + + return $path; + } + + private function scriptsContainPath(array $scripts, string $path): bool + { + foreach ($scripts as $script) { + if (str_starts_with($script, $path)) { + return true; + } + } + + return false; + } + + private function deleteDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $files = array_diff(scandir($directory), ['.', '..']); + foreach ($files as $file) { + $path = $directory . '/' . $file; + if (is_dir($path) && !is_link($path)) { + $this->deleteDirectory($path); + } else { + unlink($path); + } + } + + rmdir($directory); + } +} diff --git a/tests/Model/PluginTest.php b/tests/Model/PluginTest.php new file mode 100644 index 0000000000..f0dac6c99c --- /dev/null +++ b/tests/Model/PluginTest.php @@ -0,0 +1,141 @@ +tempDir = sys_get_temp_dir() . '/plugin-test-' . uniqid(); + mkdir($this->tempDir, 0755, true); + } + + public function tearDownTestPlugin() + { + if ($this->tempDir && is_dir($this->tempDir)) { + $this->deleteDirectory($this->tempDir); + } + } + + public function testFromPathCreatesPluginInstance() + { + $this->setUpTestPlugin(); + $plugin = Plugin::fromPath($this->tempDir); + + $this->assertInstanceOf(Plugin::class, $plugin); + $this->assertEquals(basename($this->tempDir), $plugin->getName()); + $this->assertEquals($this->tempDir, $plugin->getPath()); + } + + public function testFromPathThrowsExceptionForNonExistentDirectory() + { + $this->expectException(RuntimeException::class); + Plugin::fromPath('/non/existent/path'); + } + + public function testGetComposerJsonReadsAndParsesFile() + { + $this->setUpTestPlugin(); + $composerJson = [ + 'name' => 'test/plugin', + 'description' => 'Test plugin', + 'autoload' => [ + 'psr-4' => [ + 'ProcessMaker\\Plugins\\Test\\' => 'src/', + ], + ], + ]; + + file_put_contents( + $this->tempDir . '/composer.json', + json_encode($composerJson, JSON_PRETTY_PRINT) + ); + + $plugin = Plugin::fromPath($this->tempDir); + $result = $plugin->getComposerJson(); + + $this->assertEquals($composerJson, $result); + } + + public function testGetComposerJsonThrowsExceptionForMissingFile() + { + $this->setUpTestPlugin(); + $plugin = Plugin::fromPath($this->tempDir); + + $this->expectException(RuntimeException::class); + $plugin->getComposerJson(); + } + + public function testGetComposerJsonThrowsExceptionForInvalidJson() + { + $this->setUpTestPlugin(); + file_put_contents($this->tempDir . '/composer.json', '{ invalid json }'); + + $plugin = Plugin::fromPath($this->tempDir); + + $this->expectException(RuntimeException::class); + $plugin->getComposerJson(); + } + + public function testGetDescriptionReturnsDescriptionFromComposerJson() + { + $this->setUpTestPlugin(); + $composerJson = [ + 'name' => 'test/plugin', + 'description' => 'Test plugin description', + ]; + + file_put_contents( + $this->tempDir . '/composer.json', + json_encode($composerJson, JSON_PRETTY_PRINT) + ); + + $plugin = Plugin::fromPath($this->tempDir); + $this->assertEquals('Test plugin description', $plugin->getDescription()); + } + + public function testGetDescriptionReturnsNullWhenMissing() + { + $this->setUpTestPlugin(); + $composerJson = ['name' => 'test/plugin']; + + file_put_contents( + $this->tempDir . '/composer.json', + json_encode($composerJson, JSON_PRETTY_PRINT) + ); + + $plugin = Plugin::fromPath($this->tempDir); + $this->assertNull($plugin->getDescription()); + } + + public function testGetNameReturnsDirectoryName() + { + $this->setUpTestPlugin(); + $plugin = Plugin::fromPath($this->tempDir); + $this->assertEquals(basename($this->tempDir), $plugin->getName()); + } + + private function deleteDirectory($dir) + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + if (is_dir($path)) { + $this->deleteDirectory($path); + } else { + unlink($path); + } + } + rmdir($dir); + } +} diff --git a/vite.config.js b/vite.config.js index 1fb13e6cde..c25023fd92 100644 --- a/vite.config.js +++ b/vite.config.js @@ -206,6 +206,8 @@ export default defineConfig(({ mode }) => { "resources/js/vite/tasks/loaderTasks.js", "resources/js/vite/tasks/tasks.js", + "resources/js/admin/plugins/index.js", + "resources/js/tasks/loaderEdit.js", "resources/js/tasks/edit.js", "resources/js/tasks/loaderPreview.js",