@reunionstudio/airlock-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/airlock-mcp/SKILL.md +122 -0
- package/.agents/skills/airlock-mcp/agents/openai.yaml +4 -0
- package/LICENSE +187 -0
- package/README.md +126 -0
- package/SECURITY.md +31 -0
- package/bin/airlock-mcp.mjs +5 -0
- package/docs/architecture.md +82 -0
- package/docs/install-surface.md +112 -0
- package/docs/ooda-loop.md +40 -0
- package/docs/spec-workbench-architecture.md +161 -0
- package/docs/spec-workspace.md +33 -0
- package/docs/workflows.md +229 -0
- package/package.json +46 -0
- package/patterns/blank/README.md +14 -0
- package/patterns/blank/sample.records.json +19 -0
- package/patterns/blank/spec.config.json +72 -0
- package/patterns/guest-access/individual-isolation.md +27 -0
- package/patterns/guest-access/role-isolation.md +26 -0
- package/patterns/guest-access/shared-contribution.md +25 -0
- package/patterns/manifest.json +16 -0
- package/patterns/spec-types/commitment.md +24 -0
- package/patterns/spec-types/observation.md +22 -0
- package/patterns/spec-types/reconciliation.md +19 -0
- package/patterns/spec-types/reference-master-data.md +21 -0
- package/patterns/starter-posts/README.md +32 -0
- package/patterns/starter-posts/sample.records.json +27 -0
- package/patterns/starter-posts/spec.config.json +135 -0
- package/schemas/airlock-mcp-workspace.schema.json +14 -0
- package/setup.py +41 -0
- package/src/airlock_mcp/__init__.py +3 -0
- package/src/airlock_mcp/__main__.py +5 -0
- package/src/airlock_mcp/art.py +26 -0
- package/src/airlock_mcp/bootstrap.py +161 -0
- package/src/airlock_mcp/cli.py +450 -0
- package/src/airlock_mcp/jsonio.py +56 -0
- package/src/airlock_mcp/manage.py +247 -0
- package/src/airlock_mcp/models.py +43 -0
- package/src/airlock_mcp/patterns.py +49 -0
- package/src/airlock_mcp/project.py +39 -0
- package/src/airlock_mcp/records.py +43 -0
- package/src/airlock_mcp/specs.py +110 -0
- package/src/airlock_mcp/sql.py +15 -0
- package/src/airlock_mcp/summary.py +115 -0
- package/src/airlock_mcp/updater.py +76 -0
- package/src/airlock_mcp/validation.py +334 -0
- package/src/airlock_mcp/workspace.py +223 -0
- package/src/cli.mjs +89 -0
- package/src/install.mjs +100 -0
- package/src/mcp.mjs +184 -0
- package/src/text.mjs +108 -0
- package/src/workbench.mjs +368 -0
- package/workspaces/_template/brief.md +18 -0
- package/workspaces/_template/decisions.md +40 -0
- package/workspaces/_template/questions.md +9 -0
- package/workspaces/_template/review.md +21 -0
- package/workspaces/_template/sample.records.json +19 -0
- package/workspaces/_template/spec.config.json +72 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .jsonio import read_json, write_json
|
|
9
|
+
from .specs import retitle_spec, spec_name
|
|
10
|
+
from .summary import workspace_summary
|
|
11
|
+
from .validation import check_workspace
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
ARCHIVE_DIRNAME = "_archive"
|
|
15
|
+
WORKSPACE_MARKERS = ("spec.config.json", "sample.records.json")
|
|
16
|
+
DECISION_PROMPTS = (
|
|
17
|
+
"One row is:",
|
|
18
|
+
"- Observe:",
|
|
19
|
+
"- Orient:",
|
|
20
|
+
"- Decide:",
|
|
21
|
+
"- Act:",
|
|
22
|
+
"Stable ids and retry-safe keys:",
|
|
23
|
+
"Event, observed, captured, effective, or transaction timestamps:",
|
|
24
|
+
"Fields people will filter, join, audit, aggregate, or report on:",
|
|
25
|
+
"Optional context that may evolve:",
|
|
26
|
+
"Attachments and evidence metadata:",
|
|
27
|
+
"Submitter, reviewer, reader, owner, and delegation model:",
|
|
28
|
+
"States, pushback, due dates, order, or cadence:",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class WorkspaceInfo:
|
|
34
|
+
name: str
|
|
35
|
+
path: Path
|
|
36
|
+
spec_name: str
|
|
37
|
+
spec_alias: str
|
|
38
|
+
records: int
|
|
39
|
+
errors: int
|
|
40
|
+
warnings: int
|
|
41
|
+
archived: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_workspace(path: Path) -> bool:
|
|
45
|
+
return path.is_dir() and all((path / marker).exists() for marker in WORKSPACE_MARKERS)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _spec_label(spec: Any, key: str, fallback: str) -> str:
|
|
49
|
+
if isinstance(spec, dict):
|
|
50
|
+
core = spec.get("core_config")
|
|
51
|
+
if isinstance(core, dict) and isinstance(core.get(key), str) and core[key]:
|
|
52
|
+
return core[key]
|
|
53
|
+
return fallback
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def workspace_info(path: Path, *, archived: bool = False) -> WorkspaceInfo:
|
|
57
|
+
spec = read_json(path / "spec.config.json")
|
|
58
|
+
sample = read_json(path / "sample.records.json")
|
|
59
|
+
records = sample.get("records") if isinstance(sample, dict) else []
|
|
60
|
+
result = check_workspace(path)
|
|
61
|
+
return WorkspaceInfo(
|
|
62
|
+
name=path.name,
|
|
63
|
+
path=path,
|
|
64
|
+
spec_name=_spec_label(spec, "spec_name", "unknown"),
|
|
65
|
+
spec_alias=_spec_label(spec, "spec_alias", "Unknown"),
|
|
66
|
+
records=len(records) if isinstance(records, list) else 0,
|
|
67
|
+
errors=len(result.errors),
|
|
68
|
+
warnings=len(result.warnings),
|
|
69
|
+
archived=archived,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def discover_workspaces(root: Path, *, include_archived: bool = False) -> list[WorkspaceInfo]:
|
|
74
|
+
if not root.exists():
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
infos: list[WorkspaceInfo] = []
|
|
78
|
+
for path in sorted(root.iterdir(), key=lambda item: item.name.lower()):
|
|
79
|
+
if path.name.startswith(".") or path.name.startswith("_"):
|
|
80
|
+
continue
|
|
81
|
+
if is_workspace(path):
|
|
82
|
+
infos.append(workspace_info(path))
|
|
83
|
+
|
|
84
|
+
archive_root = root / ARCHIVE_DIRNAME
|
|
85
|
+
if include_archived and archive_root.exists():
|
|
86
|
+
for path in sorted(archive_root.iterdir(), key=lambda item: item.name.lower()):
|
|
87
|
+
if is_workspace(path):
|
|
88
|
+
infos.append(workspace_info(path, archived=True))
|
|
89
|
+
return infos
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def format_workspace_table(infos: list[WorkspaceInfo]) -> str:
|
|
93
|
+
if not infos:
|
|
94
|
+
return "no workspaces found"
|
|
95
|
+
rows = [
|
|
96
|
+
(
|
|
97
|
+
info.name,
|
|
98
|
+
info.spec_name,
|
|
99
|
+
str(info.records),
|
|
100
|
+
"archived" if info.archived else "active",
|
|
101
|
+
f"{info.errors}/{info.warnings}",
|
|
102
|
+
str(info.path),
|
|
103
|
+
)
|
|
104
|
+
for info in infos
|
|
105
|
+
]
|
|
106
|
+
headers = ("workspace", "spec", "records", "state", "errors/warnings", "path")
|
|
107
|
+
widths = [
|
|
108
|
+
max(len(headers[index]), *(len(row[index]) for row in rows))
|
|
109
|
+
for index in range(len(headers))
|
|
110
|
+
]
|
|
111
|
+
lines = [
|
|
112
|
+
" ".join(headers[index].ljust(widths[index]) for index in range(len(headers))),
|
|
113
|
+
" ".join("-" * width for width in widths),
|
|
114
|
+
]
|
|
115
|
+
lines.extend(" ".join(row[index].ljust(widths[index]) for index in range(len(row))) for row in rows)
|
|
116
|
+
return "\n".join(lines)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def format_workspace_paths(infos: list[WorkspaceInfo]) -> str:
|
|
120
|
+
return "\n".join(str(info.path) for info in infos)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def archive_workspace(source: Path, *, archive_root: Path | None = None) -> Path:
|
|
124
|
+
if not is_workspace(source):
|
|
125
|
+
raise FileNotFoundError(source)
|
|
126
|
+
target_root = archive_root or source.parent / ARCHIVE_DIRNAME
|
|
127
|
+
target = target_root / source.name
|
|
128
|
+
if target.exists():
|
|
129
|
+
raise FileExistsError(target)
|
|
130
|
+
target_root.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
shutil.move(str(source), str(target))
|
|
132
|
+
return target
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def restore_workspace(source: Path, *, output_root: Path | None = None) -> Path:
|
|
136
|
+
if not is_workspace(source):
|
|
137
|
+
raise FileNotFoundError(source)
|
|
138
|
+
if output_root is None and source.parent.name != ARCHIVE_DIRNAME:
|
|
139
|
+
raise ValueError("restore source must be under _archive or --output must be provided")
|
|
140
|
+
target_root = output_root or source.parent.parent
|
|
141
|
+
target = target_root / source.name
|
|
142
|
+
if target.exists():
|
|
143
|
+
raise FileExistsError(target)
|
|
144
|
+
target_root.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
shutil.move(str(source), str(target))
|
|
146
|
+
return target
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def rename_workspace(
|
|
150
|
+
source: Path,
|
|
151
|
+
new_name: str,
|
|
152
|
+
*,
|
|
153
|
+
output_root: Path | None = None,
|
|
154
|
+
retitle: bool = True,
|
|
155
|
+
) -> Path:
|
|
156
|
+
if not is_workspace(source):
|
|
157
|
+
raise FileNotFoundError(source)
|
|
158
|
+
target_root = output_root or source.parent
|
|
159
|
+
target = target_root / new_name
|
|
160
|
+
if target.exists():
|
|
161
|
+
raise FileExistsError(target)
|
|
162
|
+
|
|
163
|
+
spec = read_json(source / "spec.config.json")
|
|
164
|
+
sample = read_json(source / "sample.records.json")
|
|
165
|
+
old_spec_name = spec_name(spec) if isinstance(spec, dict) else None
|
|
166
|
+
|
|
167
|
+
target_root.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
shutil.move(str(source), str(target))
|
|
169
|
+
|
|
170
|
+
if retitle and isinstance(spec, dict):
|
|
171
|
+
updated = retitle_spec(spec, new_name)
|
|
172
|
+
new_spec_name = spec_name(updated)
|
|
173
|
+
write_json(target / "spec.config.json", updated, force=True)
|
|
174
|
+
if isinstance(sample, dict):
|
|
175
|
+
sample["spec_name"] = new_spec_name
|
|
176
|
+
if sample.get("filename") in {None, "", f"{old_spec_name}_001"}:
|
|
177
|
+
sample["filename"] = f"{new_spec_name}_001"
|
|
178
|
+
write_json(target / "sample.records.json", sample, force=True)
|
|
179
|
+
return target
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _line_answers_prompt(line: str, prompt: str) -> bool:
|
|
183
|
+
stripped = line.strip()
|
|
184
|
+
return stripped.startswith(prompt) and stripped != prompt
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _is_prompt_line(line: str) -> bool:
|
|
188
|
+
stripped = line.strip()
|
|
189
|
+
return any(stripped.startswith(prompt) for prompt in DECISION_PROMPTS)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _has_following_answer(lines: list[str], index: int) -> bool:
|
|
193
|
+
for line in lines[index + 1 :]:
|
|
194
|
+
stripped = line.strip()
|
|
195
|
+
if not stripped:
|
|
196
|
+
continue
|
|
197
|
+
if stripped.startswith("## ") or _is_prompt_line(stripped):
|
|
198
|
+
return False
|
|
199
|
+
return True
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _text_has_open_placeholders(path: Path) -> bool:
|
|
204
|
+
if not path.exists():
|
|
205
|
+
return True
|
|
206
|
+
text = path.read_text(encoding="utf-8")
|
|
207
|
+
if not text.strip():
|
|
208
|
+
return True
|
|
209
|
+
lines = text.splitlines()
|
|
210
|
+
seen_prompt = False
|
|
211
|
+
for index, line in enumerate(lines):
|
|
212
|
+
for prompt in DECISION_PROMPTS:
|
|
213
|
+
if line.strip().startswith(prompt):
|
|
214
|
+
seen_prompt = True
|
|
215
|
+
if not _line_answers_prompt(line, prompt) and not _has_following_answer(lines, index):
|
|
216
|
+
return True
|
|
217
|
+
return not seen_prompt
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def next_steps(workspace: Path) -> str:
|
|
221
|
+
spec = read_json(workspace / "spec.config.json")
|
|
222
|
+
sample = read_json(workspace / "sample.records.json")
|
|
223
|
+
result = check_workspace(workspace)
|
|
224
|
+
lines = [workspace_summary(workspace, spec if isinstance(spec, dict) else {}, sample if isinstance(sample, dict) else {}, result)]
|
|
225
|
+
lines.append("")
|
|
226
|
+
lines.append("next:")
|
|
227
|
+
if result.errors:
|
|
228
|
+
lines.append("1. Fix local check errors before exporting CSV or rendering SQL.")
|
|
229
|
+
lines.append(f"2. Run `airlock-mcp check {workspace}` again.")
|
|
230
|
+
return "\n".join(lines)
|
|
231
|
+
if result.warnings:
|
|
232
|
+
lines.append("1. Review local warnings and either fix them or record the deliberate exception in review.md.")
|
|
233
|
+
lines.append(f"2. Run `airlock-mcp summary {workspace}` to confirm the shape.")
|
|
234
|
+
return "\n".join(lines)
|
|
235
|
+
if _text_has_open_placeholders(workspace / "decisions.md"):
|
|
236
|
+
lines.append("1. Fill in decisions.md for row grain, identifiers, time, evidence, access, and OODA.")
|
|
237
|
+
lines.append(f"2. Run `airlock-mcp next {workspace}` when the design notes are current.")
|
|
238
|
+
return "\n".join(lines)
|
|
239
|
+
records = sample.get("records") if isinstance(sample, dict) else []
|
|
240
|
+
if not records:
|
|
241
|
+
lines.append("1. Add at least one realistic sample record.")
|
|
242
|
+
lines.append(f"2. Run `airlock-mcp check {workspace}`.")
|
|
243
|
+
return "\n".join(lines)
|
|
244
|
+
lines.append(f"1. Export sample CSV: `airlock-mcp export-csv {workspace}`.")
|
|
245
|
+
lines.append(f"2. Render validate-only SQL: `airlock-mcp render-sql {workspace}`.")
|
|
246
|
+
lines.append("3. Run installed Airlock validation before any mutating create or alter call.")
|
|
247
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Pattern:
|
|
10
|
+
name: str
|
|
11
|
+
title: str
|
|
12
|
+
summary: str
|
|
13
|
+
directory: Path
|
|
14
|
+
spec_config_path: Path
|
|
15
|
+
sample_records_path: Path
|
|
16
|
+
readme_path: Path | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Finding:
|
|
21
|
+
level: str
|
|
22
|
+
path: str
|
|
23
|
+
message: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class CheckResult:
|
|
28
|
+
findings: tuple[Finding, ...]
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def errors(self) -> tuple[Finding, ...]:
|
|
32
|
+
return tuple(finding for finding in self.findings if finding.level == "error")
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def warnings(self) -> tuple[Finding, ...]:
|
|
36
|
+
return tuple(finding for finding in self.findings if finding.level == "warning")
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def ok(self) -> bool:
|
|
40
|
+
return not self.errors
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
JsonObject = dict[str, Any]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .jsonio import read_json
|
|
6
|
+
from .models import Pattern
|
|
7
|
+
from .project import patterns_dir
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_patterns(root: Path | None = None) -> dict[str, Pattern]:
|
|
11
|
+
base = patterns_dir(root)
|
|
12
|
+
manifest = read_json(base / "manifest.json")
|
|
13
|
+
if not isinstance(manifest, dict):
|
|
14
|
+
raise RuntimeError(f"Pattern manifest not found or invalid: {base / 'manifest.json'}")
|
|
15
|
+
|
|
16
|
+
patterns: dict[str, Pattern] = {}
|
|
17
|
+
for raw in manifest.get("patterns", []):
|
|
18
|
+
if not isinstance(raw, dict):
|
|
19
|
+
continue
|
|
20
|
+
name = str(raw.get("name") or "").strip()
|
|
21
|
+
directory_name = str(raw.get("directory") or name).strip()
|
|
22
|
+
if not name:
|
|
23
|
+
continue
|
|
24
|
+
directory = base / directory_name
|
|
25
|
+
pattern = Pattern(
|
|
26
|
+
name=name,
|
|
27
|
+
title=str(raw.get("title") or name),
|
|
28
|
+
summary=str(raw.get("summary") or ""),
|
|
29
|
+
directory=directory,
|
|
30
|
+
spec_config_path=directory / "spec.config.json",
|
|
31
|
+
sample_records_path=directory / "sample.records.json",
|
|
32
|
+
readme_path=(directory / "README.md") if (directory / "README.md").exists() else None,
|
|
33
|
+
)
|
|
34
|
+
patterns[name] = pattern
|
|
35
|
+
return patterns
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_pattern_spec(pattern: Pattern) -> dict:
|
|
39
|
+
spec = read_json(pattern.spec_config_path)
|
|
40
|
+
if not isinstance(spec, dict):
|
|
41
|
+
raise RuntimeError(f"Pattern spec config is missing or invalid: {pattern.spec_config_path}")
|
|
42
|
+
return spec
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_pattern_records(pattern: Pattern) -> dict:
|
|
46
|
+
records = read_json(pattern.sample_records_path)
|
|
47
|
+
if not isinstance(records, dict):
|
|
48
|
+
raise RuntimeError(f"Pattern sample records are missing or invalid: {pattern.sample_records_path}")
|
|
49
|
+
return records
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sysconfig
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def repo_root() -> Path:
|
|
9
|
+
"""Resolve the Airlock MCP checkout root.
|
|
10
|
+
|
|
11
|
+
`AIRLOCK_MCP_HOME` can point the CLI at a different checkout. Otherwise,
|
|
12
|
+
walk upward from this module and the current directory looking for the
|
|
13
|
+
pattern manifest. Editable installs use the module path; direct source runs
|
|
14
|
+
often use the current working directory.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
override = os.environ.get("AIRLOCK_MCP_HOME") or os.environ.get("AIRLOCK_SMITH_HOME")
|
|
18
|
+
if override:
|
|
19
|
+
return Path(override).expanduser().resolve()
|
|
20
|
+
|
|
21
|
+
candidates = [Path(__file__).resolve(), Path.cwd().resolve()]
|
|
22
|
+
for start in candidates:
|
|
23
|
+
for parent in [start, *start.parents]:
|
|
24
|
+
if (parent / "patterns" / "manifest.json").exists():
|
|
25
|
+
return parent
|
|
26
|
+
|
|
27
|
+
installed_data = Path(sysconfig.get_path("data")) / "airlock_mcp"
|
|
28
|
+
if (installed_data / "patterns" / "manifest.json").exists():
|
|
29
|
+
return installed_data
|
|
30
|
+
|
|
31
|
+
return Path(__file__).resolve().parents[2]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def patterns_dir(root: Path | None = None) -> Path:
|
|
35
|
+
return (root or repo_root()) / "patterns"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def workspace_template_dir(root: Path | None = None) -> Path:
|
|
39
|
+
return (root or repo_root()) / "workspaces" / "_template"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def column_names(spec_config: dict[str, Any]) -> list[str]:
|
|
10
|
+
columns = spec_config.get("column_config")
|
|
11
|
+
if not isinstance(columns, list):
|
|
12
|
+
return []
|
|
13
|
+
names: list[str] = []
|
|
14
|
+
for column in columns:
|
|
15
|
+
if isinstance(column, dict) and isinstance(column.get("name"), str) and column["name"]:
|
|
16
|
+
names.append(column["name"])
|
|
17
|
+
return names
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _csv_value(value: Any) -> Any:
|
|
21
|
+
if value is None:
|
|
22
|
+
return ""
|
|
23
|
+
if isinstance(value, (dict, list)):
|
|
24
|
+
return json.dumps(value, separators=(",", ":"), sort_keys=True)
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def records_to_csv(spec_config: dict[str, Any], sample_records: dict[str, Any]) -> str:
|
|
29
|
+
"""Render sample.records.json records as CSV in spec column order."""
|
|
30
|
+
|
|
31
|
+
fields = column_names(spec_config)
|
|
32
|
+
records = sample_records.get("records")
|
|
33
|
+
if not isinstance(records, list):
|
|
34
|
+
records = []
|
|
35
|
+
|
|
36
|
+
output = StringIO()
|
|
37
|
+
writer = csv.DictWriter(output, fieldnames=fields, extrasaction="ignore", lineterminator="\n")
|
|
38
|
+
writer.writeheader()
|
|
39
|
+
for raw_record in records:
|
|
40
|
+
if not isinstance(raw_record, dict):
|
|
41
|
+
continue
|
|
42
|
+
writer.writerow({field: _csv_value(raw_record.get(field)) for field in fields})
|
|
43
|
+
return output.getvalue()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .jsonio import coerce_json_object
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def slug_to_snake(value: str) -> str:
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
cleaned = re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").lower()
|
|
13
|
+
return cleaned or "draft_spec"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def spec_name(spec_config: dict[str, Any]) -> str:
|
|
17
|
+
core = spec_config.get("core_config")
|
|
18
|
+
if isinstance(core, dict):
|
|
19
|
+
name = core.get("spec_name")
|
|
20
|
+
if isinstance(name, str) and name.strip():
|
|
21
|
+
return name.strip()
|
|
22
|
+
return "draft_spec"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def retitle_spec(spec_config: dict[str, Any], workspace_name: str) -> dict[str, Any]:
|
|
26
|
+
updated = copy.deepcopy(spec_config)
|
|
27
|
+
core = updated.setdefault("core_config", {})
|
|
28
|
+
if isinstance(core, dict):
|
|
29
|
+
new_name = slug_to_snake(workspace_name)
|
|
30
|
+
core["spec_name"] = new_name
|
|
31
|
+
core["spec_alias"] = workspace_name.replace("-", " ").replace("_", " ").title()
|
|
32
|
+
return updated
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_spec_config(raw: Any) -> dict[str, Any]:
|
|
36
|
+
"""Extract canonical spec config from common Airlock/spec-library shapes."""
|
|
37
|
+
|
|
38
|
+
data = coerce_json_object(raw)
|
|
39
|
+
if not isinstance(data, dict):
|
|
40
|
+
raise ValueError("Expected a JSON object containing a spec config.")
|
|
41
|
+
|
|
42
|
+
for key in ("specConfig", "spec_config", "SPEC_CONFIG"):
|
|
43
|
+
if key in data:
|
|
44
|
+
extracted = coerce_json_object(data[key])
|
|
45
|
+
if isinstance(extracted, dict):
|
|
46
|
+
return extracted
|
|
47
|
+
|
|
48
|
+
if "core_config" in data or "column_config" in data:
|
|
49
|
+
return data
|
|
50
|
+
|
|
51
|
+
specs = data.get("specs")
|
|
52
|
+
if isinstance(specs, list) and specs:
|
|
53
|
+
first = specs[0]
|
|
54
|
+
if isinstance(first, dict):
|
|
55
|
+
config = first.get("config") or first
|
|
56
|
+
if isinstance(config, dict):
|
|
57
|
+
core = {
|
|
58
|
+
"spec_name": data.get("specName") or data.get("SPEC_NAME") or config.get("spec_name") or "imported_spec",
|
|
59
|
+
"spec_alias": data.get("specAlias") or data.get("SPEC_ALIAS") or data.get("specName") or "Imported Spec",
|
|
60
|
+
"description": data.get("summary") or data.get("description") or "",
|
|
61
|
+
"owner_role": config.get("owner_role") or "app_admin",
|
|
62
|
+
"is_published": False,
|
|
63
|
+
"is_archived": False,
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
"core_config": core,
|
|
67
|
+
"column_config": config.get("columns") or config.get("column_config") or [],
|
|
68
|
+
"file_rules": {"file_format": config.get("file_format") or {}},
|
|
69
|
+
"rules": config.get("rules") or [],
|
|
70
|
+
"attachment_policy": config.get("attachment_policy") or {},
|
|
71
|
+
"guest_access": data.get("guest_access") or config.get("guest_access") or {},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
raise ValueError("Could not find specConfig, spec_config, SPEC_CONFIG, or canonical spec keys.")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def sample_value_for_column(column: dict[str, Any]) -> Any:
|
|
78
|
+
column_type = str(column.get("type") or "string").lower()
|
|
79
|
+
name = str(column.get("name") or "field")
|
|
80
|
+
if column_type in {"number", "float", "decimal"}:
|
|
81
|
+
return 1.0
|
|
82
|
+
if column_type == "integer":
|
|
83
|
+
return 1
|
|
84
|
+
if column_type == "boolean":
|
|
85
|
+
return True
|
|
86
|
+
if column_type == "date":
|
|
87
|
+
return "2026-06-13"
|
|
88
|
+
if column_type == "datetime":
|
|
89
|
+
return "2026-06-13 09:00:00"
|
|
90
|
+
if column_type == "variant":
|
|
91
|
+
return {"source": {"system": "manual"}}
|
|
92
|
+
if name.endswith("_id"):
|
|
93
|
+
return f"{name.upper()}-001"
|
|
94
|
+
return f"example {name.replace('_', ' ')}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def sample_records_for_spec(spec_config: dict[str, Any]) -> dict[str, Any]:
|
|
98
|
+
columns = spec_config.get("column_config")
|
|
99
|
+
if not isinstance(columns, list):
|
|
100
|
+
columns = []
|
|
101
|
+
record: dict[str, Any] = {}
|
|
102
|
+
for column in columns:
|
|
103
|
+
if isinstance(column, dict) and column.get("name"):
|
|
104
|
+
record[str(column["name"])] = sample_value_for_column(column)
|
|
105
|
+
current_spec_name = spec_name(spec_config)
|
|
106
|
+
return {
|
|
107
|
+
"spec_name": current_spec_name,
|
|
108
|
+
"filename": f"{current_spec_name}_001",
|
|
109
|
+
"records": [record],
|
|
110
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .specs import spec_name
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def render_admin_sql(spec_config: dict[str, Any], *, app_name: str, operation: str) -> str:
|
|
10
|
+
body = json.dumps(spec_config, indent=2)
|
|
11
|
+
current_spec = spec_name(spec_config)
|
|
12
|
+
if operation == "alter":
|
|
13
|
+
escaped = current_spec.replace("'", "''")
|
|
14
|
+
return f"CALL {app_name}.admin.alter_spec('{escaped}', PARSE_JSON($${body}$$), TRUE);"
|
|
15
|
+
return f"CALL {app_name}.admin.create_spec(PARSE_JSON($${body}$$), TRUE);"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .models import CheckResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _core(spec_config: dict[str, Any]) -> dict[str, Any]:
|
|
10
|
+
core = spec_config.get("core_config")
|
|
11
|
+
return core if isinstance(core, dict) else {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _columns(spec_config: dict[str, Any]) -> list[dict[str, Any]]:
|
|
15
|
+
columns = spec_config.get("column_config")
|
|
16
|
+
return [column for column in columns if isinstance(column, dict)] if isinstance(columns, list) else []
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _variant_shape_fields(spec_config: dict[str, Any]) -> set[str]:
|
|
20
|
+
rules = spec_config.get("rules")
|
|
21
|
+
if not isinstance(rules, list):
|
|
22
|
+
return set()
|
|
23
|
+
fields: set[str] = set()
|
|
24
|
+
for rule in rules:
|
|
25
|
+
if not isinstance(rule, dict) or rule.get("type") != "variant_shape":
|
|
26
|
+
continue
|
|
27
|
+
field = rule.get("field", rule.get("column"))
|
|
28
|
+
if isinstance(field, str) and field:
|
|
29
|
+
fields.add(field)
|
|
30
|
+
return fields
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _guest_access_summary(spec_config: dict[str, Any]) -> str:
|
|
34
|
+
guest_access = spec_config.get("guest_access")
|
|
35
|
+
if not isinstance(guest_access, dict):
|
|
36
|
+
return "not configured"
|
|
37
|
+
if guest_access.get("isolated_directories_enabled") is True:
|
|
38
|
+
return "isolated directories enabled"
|
|
39
|
+
public_folder = guest_access.get("public_folder")
|
|
40
|
+
if not isinstance(public_folder, dict) or public_folder.get("enabled") is not True:
|
|
41
|
+
return "configured without public folder"
|
|
42
|
+
subfolders = public_folder.get("subfolders")
|
|
43
|
+
if not isinstance(subfolders, dict):
|
|
44
|
+
return "shared public folder"
|
|
45
|
+
enabled = [
|
|
46
|
+
name
|
|
47
|
+
for name, value in subfolders.items()
|
|
48
|
+
if isinstance(value, dict) and value.get("enabled") is True
|
|
49
|
+
]
|
|
50
|
+
return "shared public folder: " + (", ".join(enabled) if enabled else "no enabled subfolders")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _attachment_summary(spec_config: dict[str, Any]) -> str:
|
|
54
|
+
policy = spec_config.get("attachment_policy")
|
|
55
|
+
if not isinstance(policy, dict):
|
|
56
|
+
return "not configured"
|
|
57
|
+
if policy.get("attachments_enabled") is not True:
|
|
58
|
+
return "disabled"
|
|
59
|
+
return "required" if policy.get("attachment_required") is True else "optional"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _text_status(workspace: Path, filename: str) -> str:
|
|
63
|
+
path = workspace / filename
|
|
64
|
+
if not path.exists():
|
|
65
|
+
return f"{filename}: missing"
|
|
66
|
+
return f"{filename}: filled" if path.read_text(encoding="utf-8").strip() else f"{filename}: empty"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def workspace_summary(
|
|
70
|
+
workspace: Path,
|
|
71
|
+
spec_config: dict[str, Any],
|
|
72
|
+
sample_records: dict[str, Any],
|
|
73
|
+
result: CheckResult,
|
|
74
|
+
) -> str:
|
|
75
|
+
core = _core(spec_config)
|
|
76
|
+
columns = _columns(spec_config)
|
|
77
|
+
required = [
|
|
78
|
+
str(column.get("name"))
|
|
79
|
+
for column in columns
|
|
80
|
+
if isinstance(column.get("tests"), list) and "not_null" in column["tests"]
|
|
81
|
+
]
|
|
82
|
+
variants = [str(column.get("name")) for column in columns if column.get("type") == "variant"]
|
|
83
|
+
variant_shapes = _variant_shape_fields(spec_config)
|
|
84
|
+
records = sample_records.get("records")
|
|
85
|
+
record_count = len(records) if isinstance(records, list) else 0
|
|
86
|
+
file_rules = spec_config.get("file_rules")
|
|
87
|
+
file_format = file_rules.get("file_format") if isinstance(file_rules, dict) else None
|
|
88
|
+
file_type = file_format.get("file_type") if isinstance(file_format, dict) else "unknown"
|
|
89
|
+
|
|
90
|
+
lines = [
|
|
91
|
+
f"workspace: {workspace}",
|
|
92
|
+
f"spec: {core.get('spec_name', 'unknown')} ({core.get('spec_alias', 'no alias')})",
|
|
93
|
+
f"owner_role: {core.get('owner_role', 'unknown')}",
|
|
94
|
+
f"columns: {len(columns)} total, {len(required)} required, {len(variants)} variant",
|
|
95
|
+
"required_fields: " + (", ".join(required) if required else "none"),
|
|
96
|
+
"variant_fields: "
|
|
97
|
+
+ (
|
|
98
|
+
", ".join(
|
|
99
|
+
f"{name}{' shaped' if name in variant_shapes else ' unshaped'}" for name in variants
|
|
100
|
+
)
|
|
101
|
+
if variants
|
|
102
|
+
else "none"
|
|
103
|
+
),
|
|
104
|
+
f"sample_records: {record_count}",
|
|
105
|
+
f"file_type: {file_type}",
|
|
106
|
+
f"attachments: {_attachment_summary(spec_config)}",
|
|
107
|
+
f"guest_access: {_guest_access_summary(spec_config)}",
|
|
108
|
+
"notes: "
|
|
109
|
+
+ "; ".join(
|
|
110
|
+
_text_status(workspace, filename)
|
|
111
|
+
for filename in ("brief.md", "decisions.md", "questions.md", "review.md")
|
|
112
|
+
),
|
|
113
|
+
f"check: {len(result.errors)} error(s), {len(result.warnings)} warning(s)",
|
|
114
|
+
]
|
|
115
|
+
return "\n".join(lines)
|