feat(project-bootstrap): .mappa маркер при создании проекта — шаг 5.8 + рендер-ассет, project-create шаг 5.5 (wiki:3340, task:1583) [v3.3.0]
This commit is contained in:
BIN
dist/project-bootstrap.skill
vendored
BIN
dist/project-bootstrap.skill
vendored
Binary file not shown.
@@ -44,7 +44,7 @@ function New-SkillArchive {
|
||||
[System.IO.Compression.ZipArchiveMode]::Create
|
||||
)
|
||||
try {
|
||||
$files = Get-ChildItem -Path $sourceFull -Recurse -File
|
||||
$files = Get-ChildItem -Path $sourceFull -Recurse -File | Where-Object { $_.FullName -notmatch '__pycache__' }
|
||||
foreach ($file in $files) {
|
||||
$rel = $file.FullName.Substring($sourceFull.Length + 1) -replace '\\','/'
|
||||
$entryName = "$SkillName/$rel"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: project-bootstrap
|
||||
author: ours
|
||||
version: 3.2.0
|
||||
version: 3.3.0
|
||||
description: >
|
||||
Initializes or upgrades a project in the current folder: git, .gitignore, README.md,
|
||||
.wiki/ using Karpathy's method, .tasks/ for task tracking, AGENTS.md (canon) with
|
||||
@@ -9,6 +9,7 @@ description: >
|
||||
Creates remote Gitea repo and syncs projects-meta cache for greenfield projects.
|
||||
Includes the mappa-bootstrap-project module (v3, решение 4 mappa-as-product):
|
||||
mappa MCP connect + mappa-конвенции + методика-install (версия в манифест).
|
||||
Creates the `.mappa` marker (wiki:3340) so the folder is a mappa project.
|
||||
Use this skill when the user says "initialize project", "bootstrap", "setup project",
|
||||
"upgrade project", "add wiki", "add tasks", "start project", "set everything up",
|
||||
"create new project", or launches the agent in a new folder and wants a full setup.
|
||||
@@ -36,6 +37,7 @@ ls -A 2>/dev/null | grep -q . && echo "empty:no" || echo "empty:yes"
|
||||
[ -d .tasks ] && echo "tasks:yes" || echo "tasks:no"
|
||||
[ -f CLAUDE.md ] && echo "claude:yes" || echo "claude:no"
|
||||
[ -f README.md ] && echo "readme:yes" || echo "readme:no"
|
||||
[ -d .mappa ] && echo "mappa-marker:yes" || echo "mappa-marker:no"
|
||||
```
|
||||
|
||||
Determine mode:
|
||||
@@ -682,6 +684,46 @@ install-команда — скил mappa-bootstrap, см. 5.7.3).
|
||||
|
||||
---
|
||||
|
||||
## Step 5.8 — `.mappa` маркер (контракт wiki:3340)
|
||||
|
||||
Машиночитаемый маркер проекта в корне папки (схема v1 — `.mappa/config.yaml`):
|
||||
гейт mappa-скилов («без маркера папка не участвует в mappa-операциях»,
|
||||
task:1546) + признак корня проекта для харнессов. Создаётся на bootstrap —
|
||||
без ручного прогона генератора (task:1583). Детерминированный рендер:
|
||||
фиксированный порядок полей, без секретов, без timestamp — повторный запуск
|
||||
no-op (`keep`).
|
||||
|
||||
1. **Собрать значения** (реестр mappa → локальное знание):
|
||||
- `project` — канон папки (`basename "$PWD"`); если проект уже в реестре
|
||||
(`projects_resolve`) — сверить, не расходится ли;
|
||||
- `tenant` — `MAPPA_TENANT` (по умолчанию `vitya`);
|
||||
- `url` — `MAPPA_CORE_URL` (без trailing slash);
|
||||
- `git_provider` — из реестра `projects.git_provider` (например `gitea`),
|
||||
иначе из шага 1.5 (создано через Gitea API → gitea); опционально;
|
||||
- `git` — `projects.qualified` (owner/repo) из реестра, иначе из remote
|
||||
шага 1.5; опционально (опустить, если неизвестно).
|
||||
2. **Записать маркер** (скрипт — ассет этого скила, реализует контракт
|
||||
wiki:3340; в репо: `skills/project-bootstrap/assets/dot_mappa_marker.py`):
|
||||
|
||||
```bash
|
||||
python assets/dot_mappa_marker.py write \
|
||||
--project "$(basename "$PWD")" --tenant vitya --url "$MAPPA_CORE_URL" \
|
||||
--git-provider gitea --git "$OWNER/$REPO"
|
||||
```
|
||||
|
||||
Без `--git-provider`/`--git`, если поля неизвестны. Повторный прогон —
|
||||
no-op (`keep`); отличающийся существующий маркер без `--force` НЕ
|
||||
перезаписывается — покажи diff и спроси (правило «never overwrite»).
|
||||
3. **Верифицировать**: `python assets/dot_mappa_marker.py check` → exit 0.
|
||||
4. **Контракт-тест** (TDD, task:1583): `python assets/test_dot_mappa_marker.py`
|
||||
— «после bootstrap есть `.mappa/config.yaml`», детерминизм, без секретов,
|
||||
порядок полей, идемпотентность.
|
||||
|
||||
Маркер публичен (без секретов) и попадает в коммит шага 6. Валидный
|
||||
существующий маркер не трогаем.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Commit
|
||||
|
||||
```bash
|
||||
@@ -706,6 +748,7 @@ Print a final report:
|
||||
✅ Done! Created:
|
||||
.wiki/ — project wiki (Karpathy method)
|
||||
.tasks/ — task tracking system
|
||||
.mappa/ — mappa project marker (wiki:3340, schema v1)
|
||||
AGENTS.md — skill triggers (canon)
|
||||
CLAUDE.md — legacy pointer
|
||||
.gitignore — standard template
|
||||
|
||||
263
skills/project-bootstrap/assets/dot_mappa_marker.py
Normal file
263
skills/project-bootstrap/assets/dot_mappa_marker.py
Normal file
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""dot_mappa_marker.py — deterministic render + write of the `.mappa` marker.
|
||||
|
||||
Contract: mappa wiki:3340 (concepts/dot-mappa-marker), schema v1.
|
||||
Used by project-create (step 5.5) and project-bootstrap (step 5.8) so a project
|
||||
folder gets its marker at create time — no manual generator run needed
|
||||
(task:1583). The batch generator (mappa `server/scripts/gen-dot-mappa-markers.ts`)
|
||||
remains for registry-wide migration; this is the per-project create path.
|
||||
|
||||
Guarantees (the contract):
|
||||
* `.mappa/config.yaml` — каталог + файл внутри
|
||||
* fixed field order (schema_version, protocol_version, project, tenant, url,
|
||||
git_provider?, git?)
|
||||
* deterministic render — no timestamps, same input → same bytes
|
||||
* NO secrets — only public registry fields; url with credentials is rejected
|
||||
* optional fields (`git_provider`, `git`) omitted when absent
|
||||
* idempotent write: same content → no-op (keep); different content → refuse
|
||||
without --force
|
||||
|
||||
Usage:
|
||||
python dot_mappa_marker.py render --project NAME --tenant TENANT --url URL \
|
||||
[--git-provider P] [--git OWNER/REPO] # print content to stdout
|
||||
python dot_mappa_marker.py write --project NAME --tenant TENANT --url URL \
|
||||
[--git-provider P] [--git OWNER/REPO] [--dir PATH] [--force] # write marker
|
||||
python dot_mappa_marker.py check --dir PATH # verify existing marker
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
PROTOCOL_VERSION = 1
|
||||
|
||||
# Canonical header comment — same as the contract example (wiki:3340).
|
||||
HEADER = "# mappa project marker — machine-readable identifier of a mappa project folder"
|
||||
|
||||
# YAML: these are indicator characters / reserved tokens — never plain.
|
||||
_INDICATOR_START = set("!&*{}[],#|>@`\"'%?:~-")
|
||||
_RESERVED_PLAIN = {"null", "Null", "NULL", "~", "true", "True", "TRUE", "false",
|
||||
"False", "FALSE", "yes", "Yes", "YES", "no", "No", "NO",
|
||||
"on", "On", "ON", "off", "Off", "OFF", "-", "?", ":"}
|
||||
|
||||
|
||||
class MarkerConflict(Exception):
|
||||
"""An existing marker differs from the requested one and --force is absent."""
|
||||
|
||||
|
||||
def yaml_scalar(value: str) -> str:
|
||||
"""YAML plain-safe scalar: plain without quotes when safe, else double-quoted.
|
||||
|
||||
Plain-safe = non-empty, no leading indicator, not a reserved token, no flow
|
||||
chars, no embedded newlines, no surrounding whitespace. Double-quoting uses
|
||||
JSON escaping, which is a valid subset of YAML double-quoted style.
|
||||
"""
|
||||
s = str(value)
|
||||
if s == "":
|
||||
return '""'
|
||||
if s.strip() != s:
|
||||
return json.dumps(s)
|
||||
if s[0] in _INDICATOR_START or s in _RESERVED_PLAIN:
|
||||
return json.dumps(s)
|
||||
if s.startswith(("- ", "? ", ": ")):
|
||||
return json.dumps(s)
|
||||
# plain scalars stay plain unless they would confuse the parser:
|
||||
# ": " (mapping indicator), trailing ":", " #" (comment), newlines/tabs
|
||||
if ": " in s or s.endswith(":") or " #" in s or "\n" in s or "\t" in s:
|
||||
return json.dumps(s)
|
||||
return s
|
||||
|
||||
|
||||
def validate_folder_name(name: str) -> str:
|
||||
"""A folder name (canon/tenant/git_provider) must be a single sane segment."""
|
||||
if not name or name in (".", ".."):
|
||||
raise ValueError(f"invalid name {name!r}: must be a non-empty folder name")
|
||||
if any(sep in name for sep in ("/", "\\", "\x00")):
|
||||
raise ValueError(f"invalid name {name!r}: must be a single path segment")
|
||||
if name != name.strip():
|
||||
raise ValueError(f"invalid name {name!r}: no surrounding whitespace allowed")
|
||||
return name
|
||||
|
||||
|
||||
def validate_git_ref(git: str) -> str:
|
||||
"""`git` = projects.qualified (owner/repo) — no credentials, no colon."""
|
||||
g = str(git)
|
||||
if not g or "/" not in g:
|
||||
raise ValueError(f"invalid git ref {g!r}: expected owner/repo")
|
||||
if any(c in g for c in ("@", ":", " ", "\t", "\n", "\\")):
|
||||
raise ValueError(f"invalid git ref {g!r}: no credentials / separators allowed")
|
||||
if g.startswith("/") or g.endswith("/") or ".." in g.split("/"):
|
||||
raise ValueError(f"invalid git ref {g!r}: must be owner/repo, not a path")
|
||||
return g
|
||||
|
||||
|
||||
def normalize_url(url: str) -> str:
|
||||
"""Absolute http(s) URL without credentials and without trailing slash."""
|
||||
u = str(url).strip()
|
||||
if not (u.startswith("http://") or u.startswith("https://")):
|
||||
raise ValueError(f"invalid url {u!r}: must be http(s)://host...")
|
||||
authority = u.split("://", 1)[1].split("/", 1)[0]
|
||||
if "@" in authority:
|
||||
raise ValueError("url must not contain credentials (no secrets in the marker)")
|
||||
return u.rstrip("/")
|
||||
|
||||
|
||||
def render(
|
||||
project: str,
|
||||
tenant: str,
|
||||
url: str,
|
||||
git_provider: str | None = None,
|
||||
git: str | None = None,
|
||||
) -> str:
|
||||
"""Deterministic `.mappa/config.yaml` content per wiki:3340 schema v1."""
|
||||
project = validate_folder_name(project)
|
||||
tenant = validate_folder_name(tenant)
|
||||
url = normalize_url(url)
|
||||
lines = [
|
||||
HEADER,
|
||||
f"schema_version: {SCHEMA_VERSION}",
|
||||
f"protocol_version: {PROTOCOL_VERSION}",
|
||||
f"project: {yaml_scalar(project)}",
|
||||
f"tenant: {yaml_scalar(tenant)}",
|
||||
f"url: {yaml_scalar(url)}",
|
||||
]
|
||||
if git_provider:
|
||||
lines.append(f"git_provider: {yaml_scalar(validate_folder_name(git_provider))}")
|
||||
if git:
|
||||
lines.append(f"git: {yaml_scalar(validate_git_ref(git))}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _sane_dir(directory: str | Path) -> Path:
|
||||
"""Resolve the target directory; reject `..` segments and non-dirs."""
|
||||
p = Path(directory)
|
||||
if ".." in p.parts:
|
||||
raise ValueError(f"invalid directory {str(directory)!r}: '..' segments not allowed")
|
||||
if p.exists() and not p.is_dir():
|
||||
raise ValueError(f"invalid directory {str(directory)!r}: not a directory")
|
||||
return p
|
||||
|
||||
|
||||
def write_marker(directory: str | Path, content: str, force: bool = False) -> tuple[Path, str]:
|
||||
"""Write `.mappa/config.yaml` under `directory`.
|
||||
|
||||
Returns (marker_path, outcome) where outcome is one of
|
||||
"created" | "keep" (idempotent no-op) | "overwrite" (force).
|
||||
Raises MarkerConflict when an existing marker differs and force is False.
|
||||
"""
|
||||
marker = _sane_dir(directory) / ".mappa" / "config.yaml"
|
||||
if marker.exists():
|
||||
existing = marker.read_text(encoding="utf-8")
|
||||
if existing == content:
|
||||
return marker, "keep"
|
||||
if not force:
|
||||
raise MarkerConflict(
|
||||
f"{marker} already exists with different content; "
|
||||
"pass --force to overwrite (contract: no silent overwrite)"
|
||||
)
|
||||
marker.write_text(content, encoding="utf-8")
|
||||
return marker, "overwrite"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text(content, encoding="utf-8")
|
||||
return marker, "created"
|
||||
|
||||
|
||||
def _parse_marker_lines(body: str) -> list[tuple[str, str]]:
|
||||
"""(key, value) pairs of data lines — comments skipped, first colon splits."""
|
||||
pairs = []
|
||||
for line in body.splitlines():
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if ": " not in line:
|
||||
raise ValueError(f"malformed marker line (no 'key: value'): {line!r}")
|
||||
key, value = line.split(": ", 1)
|
||||
pairs.append((key, value.strip()))
|
||||
return pairs
|
||||
|
||||
|
||||
def check_marker(directory: str | Path) -> tuple[bool, str]:
|
||||
"""Gate check (wiki:3340 / task:1546): is `directory` a mappa project?
|
||||
|
||||
Returns (ok, message). ok means `.mappa/config.yaml` exists and its data
|
||||
lines start with exactly the required fields (schema_version,
|
||||
protocol_version, project, tenant, url) in canonical order with valid
|
||||
values; optional `git_provider`/`git` may follow.
|
||||
"""
|
||||
marker = _sane_dir(directory) / ".mappa" / "config.yaml"
|
||||
if not marker.is_file():
|
||||
return False, f"no marker: {marker} (folder without marker is not a mappa project)"
|
||||
try:
|
||||
pairs = _parse_marker_lines(marker.read_text(encoding="utf-8"))
|
||||
except ValueError as e:
|
||||
return False, f"marker {marker}: {e}"
|
||||
if len(pairs) < 5:
|
||||
return False, f"marker {marker}: fewer than the 5 required fields"
|
||||
required = ["schema_version", "protocol_version", "project", "tenant", "url"]
|
||||
if [k for k, _ in pairs[:5]] != required:
|
||||
return False, f"marker {marker}: field order mismatch ({[k for k, _ in pairs[:5]]})"
|
||||
values = dict(pairs)
|
||||
if values["schema_version"] != str(SCHEMA_VERSION):
|
||||
return False, f"marker {marker}: schema_version must be {SCHEMA_VERSION}"
|
||||
if values["protocol_version"] != str(PROTOCOL_VERSION):
|
||||
return False, f"marker {marker}: protocol_version must be {PROTOCOL_VERSION}"
|
||||
try:
|
||||
validate_folder_name(values["project"])
|
||||
validate_folder_name(values["tenant"])
|
||||
normalize_url(values["url"])
|
||||
if "git_provider" in values:
|
||||
validate_folder_name(values["git_provider"])
|
||||
if "git" in values:
|
||||
validate_git_ref(values["git"])
|
||||
except ValueError as e:
|
||||
return False, f"marker {marker}: {e}"
|
||||
return True, f"marker ok: {marker}"
|
||||
|
||||
|
||||
def _add_common(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--project", required=True, help="канон папки = реестр projects.name (slug)")
|
||||
parser.add_argument("--tenant", required=True, help="тенант, где живёт проект (MAPPA_TENANT)")
|
||||
parser.add_argument("--url", required=True, help="MAPPA_CORE_URL (без trailing slash)")
|
||||
parser.add_argument("--git-provider", default=None, help="projects.git_provider (gitea/...) — опционально")
|
||||
parser.add_argument("--git", default=None, help="projects.qualified (owner/repo) — опционально")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=".mappa marker per wiki:3340 (schema v1)")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_render = sub.add_parser("render", help="print deterministic marker content")
|
||||
_add_common(p_render)
|
||||
|
||||
p_write = sub.add_parser("write", help="write .mappa/config.yaml into a folder")
|
||||
_add_common(p_write)
|
||||
p_write.add_argument("--dir", default=".", help="project folder (default: cwd)")
|
||||
p_write.add_argument("--force", action="store_true", help="overwrite a differing marker")
|
||||
|
||||
p_check = sub.add_parser("check", help="gate check: is the folder a mappa project?")
|
||||
p_check.add_argument("--dir", default=".", help="project folder (default: cwd)")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.cmd in ("render", "write"):
|
||||
content = render(args.project, args.tenant, args.url, args.git_provider, args.git)
|
||||
if args.cmd == "render":
|
||||
sys.stdout.write(content)
|
||||
return 0
|
||||
marker, outcome = write_marker(args.dir, content, force=args.force)
|
||||
print(f"{outcome}: {marker}")
|
||||
return 0
|
||||
|
||||
if args.cmd == "check":
|
||||
ok, msg = check_marker(args.dir)
|
||||
print(msg)
|
||||
return 0 if ok else 1
|
||||
|
||||
return 2 # unreachable
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
290
skills/project-bootstrap/assets/test_dot_mappa_marker.py
Normal file
290
skills/project-bootstrap/assets/test_dot_mappa_marker.py
Normal file
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Contract test for the `.mappa` marker — mappa wiki:3340 (concepts/dot-mappa-marker).
|
||||
|
||||
The contract under test (task:1583): after the project-create/bootstrap marker
|
||||
step, the project folder contains `.mappa/config.yaml` matching schema v1:
|
||||
fixed field order, deterministic render (no timestamps), NO secrets, optional
|
||||
fields (`git_provider`, `git`) omitted when absent, idempotent write.
|
||||
|
||||
Run: python test_dot_mappa_marker.py (or: python -m unittest test_dot_mappa_marker)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import dot_mappa_marker as dmm # noqa: E402
|
||||
|
||||
CANON = "skills" # registry projects.name — канон папки (slug)
|
||||
TENANT = "vitya"
|
||||
URL = "https://mappa.vds.kzntsv.site"
|
||||
GIT_PROVIDER = "gitea"
|
||||
GIT = "OpeItcLoc03/skills"
|
||||
|
||||
FIELD_ORDER = [
|
||||
"schema_version",
|
||||
"protocol_version",
|
||||
"project",
|
||||
"tenant",
|
||||
"url",
|
||||
"git_provider",
|
||||
"git",
|
||||
]
|
||||
|
||||
|
||||
def field_keys(body: str) -> list[str]:
|
||||
return [
|
||||
line.split(":", 1)[0]
|
||||
for line in body.splitlines()
|
||||
if line and not line.startswith("#") and ": " in line
|
||||
]
|
||||
|
||||
|
||||
def write_contract_marker(tmp: str) -> Path:
|
||||
"""Helper: create a valid marker as the bootstrap step would."""
|
||||
marker, outcome = dmm.write_marker(tmp, dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT))
|
||||
assert outcome == "created"
|
||||
return marker
|
||||
|
||||
|
||||
class ContractTests(unittest.TestCase):
|
||||
"""Contract: after bootstrap there is `.mappa/config.yaml` (task:1583)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.tmp = tempfile.mkdtemp(prefix="mappa-marker-test-")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
# --- presence / shape -------------------------------------------------
|
||||
|
||||
def test_bootstrap_marker_step_creates_config_yaml(self) -> None:
|
||||
"""The bootstrap marker step leaves `.mappa/config.yaml` in the folder."""
|
||||
content = dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT)
|
||||
marker, outcome = dmm.write_marker(self.tmp, content)
|
||||
self.assertEqual(outcome, "created")
|
||||
self.assertTrue(marker.is_file())
|
||||
self.assertEqual(marker.name, "config.yaml")
|
||||
self.assertEqual(marker.parent.name, ".mappa")
|
||||
|
||||
def test_fixed_field_order(self) -> None:
|
||||
body = dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT)
|
||||
self.assertEqual(field_keys(body), FIELD_ORDER)
|
||||
|
||||
def test_deterministic_render_no_timestamp(self) -> None:
|
||||
a = dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT)
|
||||
b = dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT)
|
||||
self.assertEqual(a, b)
|
||||
# no ISO-date-like content
|
||||
self.assertNotRegex(a, r"\d{4}-\d{2}-\d{2}")
|
||||
|
||||
def test_optional_fields_omitted_when_absent(self) -> None:
|
||||
body = dmm.render(CANON, TENANT, URL)
|
||||
self.assertEqual(field_keys(body), FIELD_ORDER[:5])
|
||||
self.assertNotIn("git_provider", body)
|
||||
self.assertNotIn("\ngit:", body)
|
||||
|
||||
def test_no_secrets_in_marker(self) -> None:
|
||||
body = dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT)
|
||||
lowered = body.lower()
|
||||
# credentials in the url authority are rejected separately
|
||||
for secret in ("token", "password", "secret", "api_key", "key:", "@"):
|
||||
self.assertNotIn(secret, lowered)
|
||||
|
||||
# --- idempotent write --------------------------------------------------
|
||||
|
||||
def test_idempotent_write_keeps_same_content(self) -> None:
|
||||
content = dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT)
|
||||
marker, first = dmm.write_marker(self.tmp, content)
|
||||
marker, second = dmm.write_marker(self.tmp, content)
|
||||
self.assertEqual(first, "created")
|
||||
self.assertEqual(second, "keep")
|
||||
self.assertEqual(marker.read_text(encoding="utf-8"), content)
|
||||
|
||||
def test_refuses_overwrite_of_different_marker_without_force(self) -> None:
|
||||
dmm.write_marker(self.tmp, dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT))
|
||||
with self.assertRaises(dmm.MarkerConflict):
|
||||
dmm.write_marker(self.tmp, dmm.render(CANON, TENANT, URL, "github", GIT))
|
||||
|
||||
def test_force_overwrites_different_marker(self) -> None:
|
||||
dmm.write_marker(self.tmp, dmm.render(CANON, TENANT, URL, GIT_PROVIDER, GIT))
|
||||
marker, outcome = dmm.write_marker(
|
||||
self.tmp, dmm.render(CANON, TENANT, URL, "github", GIT), force=True
|
||||
)
|
||||
self.assertEqual(outcome, "overwrite")
|
||||
self.assertIn("git_provider: github", marker.read_text(encoding="utf-8"))
|
||||
|
||||
# --- input validation ---------------------------------------------------
|
||||
|
||||
def test_folder_name_path_segments_rejected(self) -> None:
|
||||
for bad in ("../evil", "a/b", "a\\b", ".", "..", ""):
|
||||
with self.assertRaises(ValueError, msg=f"name {bad!r} must be rejected"):
|
||||
dmm.render(bad, TENANT, URL)
|
||||
|
||||
def test_url_trailing_slash_stripped_but_path_kept(self) -> None:
|
||||
body = dmm.render(CANON, TENANT, URL + "//")
|
||||
self.assertIn(f"url: {URL}", body)
|
||||
# a trailing slash after a path must be stripped, the path kept
|
||||
body2 = dmm.render(CANON, TENANT, "https://example.com/mappa/")
|
||||
self.assertIn("url: https://example.com/mappa", body2)
|
||||
|
||||
def test_url_with_credentials_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
dmm.render(CANON, TENANT, "https://user:pass@mappa.vds.kzntsv.site")
|
||||
|
||||
def test_url_scheme_restricted_to_http_https(self) -> None:
|
||||
for bad in ("ftp://mappa.example", "javascript://x", "mappa.vds.kzntsv.site", "://x"):
|
||||
with self.assertRaises(ValueError, msg=f"url {bad!r} must be rejected"):
|
||||
dmm.render(CANON, TENANT, bad)
|
||||
|
||||
def test_git_ref_with_credentials_rejected(self) -> None:
|
||||
for bad in ("user:pass@host/repo", "victor/repo@token", "../config", "/owner/repo", "owner/repo/", "owner repo", "norepo"):
|
||||
with self.assertRaises(ValueError, msg=f"git {bad!r} must be rejected"):
|
||||
dmm.render(CANON, TENANT, URL, GIT_PROVIDER, bad)
|
||||
|
||||
def test_directory_with_parent_segments_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
dmm.write_marker("some/../elsewhere", dmm.render(CANON, TENANT, URL))
|
||||
with self.assertRaises(ValueError):
|
||||
dmm.check_marker("../etc")
|
||||
|
||||
# --- YAML scalar edge cases -------------------------------------------
|
||||
|
||||
def test_yaml_scalar_quoting_edge_cases(self) -> None:
|
||||
# reserved tokens / indicators must be double-quoted (never plain)
|
||||
for special in ("~", "@host", "-", "?", ":", "null", "yes", "on", "true",
|
||||
"a: b", "ends:", " #lead", "has tab\tinside"):
|
||||
self.assertTrue(dmm.yaml_scalar(special).startswith('"'),
|
||||
f"{special!r} must be double-quoted, got {dmm.yaml_scalar(special)!r}")
|
||||
# plain-safe values stay plain
|
||||
for plain in ("vitya", "OpeItcLoc03/skills", "https://mappa.vds.kzntsv.site",
|
||||
"a:b", "x#y", "lead#ing", "my-proj"):
|
||||
self.assertEqual(dmm.yaml_scalar(plain), plain)
|
||||
self.assertEqual(dmm.yaml_scalar(""), '""')
|
||||
|
||||
# --- check_marker (gate) ----------------------------------------------
|
||||
|
||||
def test_check_ok_on_valid_marker(self) -> None:
|
||||
write_contract_marker(self.tmp)
|
||||
ok, msg = dmm.check_marker(self.tmp)
|
||||
self.assertTrue(ok, msg)
|
||||
|
||||
def test_check_fails_on_missing_marker(self) -> None:
|
||||
ok, _ = dmm.check_marker(self.tmp)
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_check_fails_on_wrong_field_order(self) -> None:
|
||||
(Path(self.tmp) / ".mappa").mkdir()
|
||||
(Path(self.tmp) / ".mappa" / "config.yaml").write_text(
|
||||
"# c\nproject: skills\nschema_version: 1\nprotocol_version: 1\n"
|
||||
"tenant: vitya\nurl: https://mappa.vds.kzntsv.site\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
ok, _ = dmm.check_marker(self.tmp)
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_check_fails_on_extra_field_before_required(self) -> None:
|
||||
(Path(self.tmp) / ".mappa").mkdir()
|
||||
(Path(self.tmp) / ".mappa" / "config.yaml").write_text(
|
||||
"extra: sneaky\nschema_version: 1\nprotocol_version: 1\n"
|
||||
"project: skills\ntenant: vitya\nurl: https://mappa.vds.kzntsv.site\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
ok, _ = dmm.check_marker(self.tmp)
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_check_fails_on_wrong_versions(self) -> None:
|
||||
(Path(self.tmp) / ".mappa").mkdir()
|
||||
(Path(self.tmp) / ".mappa" / "config.yaml").write_text(
|
||||
"schema_version: 2\nprotocol_version: 1\nproject: skills\n"
|
||||
"tenant: vitya\nurl: https://mappa.vds.kzntsv.site\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
ok, _ = dmm.check_marker(self.tmp)
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_check_fails_on_malicious_project_value(self) -> None:
|
||||
(Path(self.tmp) / ".mappa").mkdir()
|
||||
(Path(self.tmp) / ".mappa" / "config.yaml").write_text(
|
||||
"schema_version: 1\nprotocol_version: 1\nproject: ../../evil\n"
|
||||
"tenant: vitya\nurl: https://mappa.vds.kzntsv.site\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
ok, _ = dmm.check_marker(self.tmp)
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_check_fails_on_malformed_line(self) -> None:
|
||||
(Path(self.tmp) / ".mappa").mkdir()
|
||||
(Path(self.tmp) / ".mappa" / "config.yaml").write_text(
|
||||
"schema_version: 1\nprotocol_version: 1\nproject skills\n"
|
||||
"tenant: vitya\nurl: https://mappa.vds.kzntsv.site\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
ok, _ = dmm.check_marker(self.tmp)
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_check_accepts_url_with_port(self) -> None:
|
||||
(Path(self.tmp) / ".mappa").mkdir()
|
||||
(Path(self.tmp) / ".mappa" / "config.yaml").write_text(
|
||||
"schema_version: 1\nprotocol_version: 1\nproject: skills\n"
|
||||
"tenant: vitya\nurl: https://mappa.example:8443\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
ok, _ = dmm.check_marker(self.tmp)
|
||||
self.assertTrue(ok)
|
||||
|
||||
# --- CLI end-to-end ----------------------------------------------------
|
||||
|
||||
def test_cli_write_creates_marker(self) -> None:
|
||||
"""End-to-end: the documented CLI command produces the marker."""
|
||||
proc = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(Path(__file__).resolve().parent / "dot_mappa_marker.py"),
|
||||
"write",
|
||||
"--project", CANON,
|
||||
"--tenant", TENANT,
|
||||
"--url", URL,
|
||||
"--git-provider", GIT_PROVIDER,
|
||||
"--git", GIT,
|
||||
"--dir", self.tmp,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
marker = Path(self.tmp) / ".mappa" / "config.yaml"
|
||||
self.assertTrue(marker.is_file())
|
||||
self.assertEqual(field_keys(marker.read_text(encoding="utf-8")), FIELD_ORDER)
|
||||
|
||||
def test_cli_check_verifies_marker(self) -> None:
|
||||
write_contract_marker(self.tmp)
|
||||
script = Path(__file__).resolve().parent / "dot_mappa_marker.py"
|
||||
ok = subprocess.run(
|
||||
[sys.executable, str(script), "check", "--dir", self.tmp],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(ok.returncode, 0, ok.stderr)
|
||||
# check on an empty dir fails (gate semantics: no marker → not a mappa project)
|
||||
empty = tempfile.mkdtemp(prefix="mappa-marker-empty-")
|
||||
try:
|
||||
missing = subprocess.run(
|
||||
[sys.executable, str(script), "check", "--dir", empty],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertNotEqual(missing.returncode, 0)
|
||||
finally:
|
||||
shutil.rmtree(empty, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,15 +1,16 @@
|
||||
---
|
||||
name: project-create
|
||||
author: ours
|
||||
version: 0.1.1
|
||||
version: 0.2.0
|
||||
description: >
|
||||
Mappa-side cycle of creating a new project: ask the operator for the hosting
|
||||
address (platform + user/org) FIRST — never derive it from neighbouring
|
||||
projects — then pre-flight checks (free in mappa AND in gitea), then create
|
||||
mappa registration and the gitea repo SIMULTANEOUSLY (repo via .admin: task
|
||||
+ covering letter, priority P0, no paired review for ops), then ask where on
|
||||
disk the project folder goes and what it's named, create the folder, and
|
||||
hand over to project-bootstrap (general skill). Triggers (bilingual):
|
||||
disk the project folder goes and what it's named, create the folder, write
|
||||
the `.mappa` marker (wiki:3340), and hand over to project-bootstrap (general
|
||||
skill). Triggers (bilingual):
|
||||
«создай проект», «заведи проект», «новый проект», «создать проект на
|
||||
гите», "create a project", "start a new project", "set up a project",
|
||||
«куда разместить проект». NOT repo content/bootstrap (→ project-bootstrap),
|
||||
@@ -51,7 +52,34 @@ Before any `projects_register` / gitea repo creation for a **new** project.
|
||||
- repo via `.admin` — `task_create` in `.admin` (**priority P0** — it blocks the project chain) + covering letter via `inbox_send` (a task on the board doesn't ping a live session). The repo task is an **ops task → NO paired review** (mappa-delegation: skip review for ops). Acceptance: repo created private, answer letter with clone URLs + which token is needed for push.
|
||||
4. **Ask the operator: where on disk the project folder goes and what it's named** (location AND folder name — both are the operator's call) — do not guess the path or the name.
|
||||
5. **Create the folder** on the agent's local filesystem (the operator's workstation, not a remote host).
|
||||
6. **Hand over to bootstrap** — `project-bootstrap` (general skill) does git init, .gitignore, README, AGENTS.md, remote connect, push. Separate step, after the repo exists.
|
||||
6. **Write the `.mappa` marker** — see Step 5.5 below. Right after the folder exists, the marker values are known from this cycle; no manual generator run.
|
||||
7. **Hand over to bootstrap** — `project-bootstrap` (general skill) does git init, .gitignore, README, AGENTS.md, remote connect, push. Separate step, after the repo exists.
|
||||
|
||||
## Step 5.5 — `.mappa` маркер (контракт wiki:3340)
|
||||
|
||||
Сразу после создания папки — маркер `.mappa/config.yaml` (гейт mappa-скилов:
|
||||
«без маркера папка не участвует в mappa-операциях», task:1546). Значения уже
|
||||
известны из этого цикла — ручной прогон генератора не нужен (task:1583):
|
||||
|
||||
- `project` — канон (имя папки, выбрано оператором в шаге 4);
|
||||
- `tenant` — `MAPPA_TENANT` (по умолчанию `vitya`);
|
||||
- `url` — `MAPPA_CORE_URL` (без trailing slash);
|
||||
- `git_provider` — платформа из шага 1 (gitea/github/…);
|
||||
- `git` — qualified (owner/repo) из ответа `.admin` (шаг 3, clone URL).
|
||||
|
||||
Запись — детерминированный рендер по контракту. Канон-скрипт — ассет
|
||||
`project-bootstrap` (в репо: `skills/project-bootstrap/assets/dot_mappa_marker.py`;
|
||||
тот же шаг в bootstrap 5.8 — повторный прогон там no-op):
|
||||
|
||||
```bash
|
||||
python <skills-repo>/skills/project-bootstrap/assets/dot_mappa_marker.py write \
|
||||
--project "$FOLDER_NAME" --tenant vitya --url "$MAPPA_CORE_URL" \
|
||||
--git-provider gitea --git "$OWNER/$REPO" --dir "$FOLDER_PATH"
|
||||
```
|
||||
|
||||
Верифицировать: `python .../dot_mappa_marker.py check --dir "$FOLDER_PATH"`
|
||||
→ exit 0. Маркер без секретов, коммитится. Если project-bootstrap не
|
||||
установлен — маркер всё равно появится на хэндовере (шаг 7 → bootstrap 5.8).
|
||||
|
||||
## Why the repo goes through `.admin`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user