@osovv/grace-cli 3.3.0 → 3.4.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/README.md CHANGED
@@ -10,13 +10,13 @@ This repository packages GRACE as reusable skills for coding agents. The current
10
10
  - knowledge-graph synchronization
11
11
  - controller-managed sequential or multi-agent implementation
12
12
 
13
- Current packaged version: `3.3.0`
13
+ Current packaged version: `3.4.0`
14
14
 
15
15
  ## What Changed In This Version
16
16
 
17
- - Removed profile selection from `grace lint`; the CLI now validates only against the current GRACE artifact set.
18
- - Limited `.grace-lint.json` to the current config schema, such as `ignoredDirs`.
19
- - Kept the role-aware/adaptive lint model while making older GRACE repos fail loudly until they are updated.
17
+ - Added a rich Python adapter without `pyright` for `grace lint`.
18
+ - Kept TypeScript/JavaScript on the TypeScript compiler API for exact export analysis.
19
+ - Made adapter failures non-fatal so linting can continue with structural checks and warnings.
20
20
 
21
21
  ## Repository Layout
22
22
 
@@ -195,6 +195,17 @@ The lint command is role-aware and adapter-aware:
195
195
  - export-map parity uses language adapters where available
196
196
  - file behavior is driven by semantic roles instead of filename masks
197
197
 
198
+ Current rich adapters:
199
+
200
+ - TypeScript/JavaScript via the TypeScript compiler API
201
+ - Python via the Python standard-library AST, without `pyright`
202
+
203
+ Current adapter behavior:
204
+
205
+ - TypeScript/JavaScript export analysis is treated as exact
206
+ - Python export analysis is exact when `__all__` is explicit, otherwise heuristic
207
+ - other languages still benefit from structural GRACE checks even when rich export analysis is not available yet
208
+
198
209
  Optional `MODULE_CONTRACT` fields for linting:
199
210
 
200
211
  ```text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osovv/grace-cli",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "GRACE CLI for linting semantic markup, contracts, and GRACE XML artifacts with a Bun-powered grace binary.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/osovv/grace-marketplace#readme",
@@ -16,6 +16,8 @@
16
16
  "cli",
17
17
  "lint",
18
18
  "bun",
19
+ "python",
20
+ "typescript",
19
21
  "semantic-markup",
20
22
  "knowledge-graph"
21
23
  ],
package/src/grace.ts CHANGED
@@ -7,7 +7,7 @@ import { lintCommand } from "./grace-lint";
7
7
  const main = defineCommand({
8
8
  meta: {
9
9
  name: "grace",
10
- version: "3.3.0",
10
+ version: "3.4.0",
11
11
  description: "GRACE CLI for linting semantic markup and GRACE project artifacts.",
12
12
  },
13
13
  subCommands: {
@@ -1,9 +1,10 @@
1
1
  import path from "node:path";
2
2
 
3
3
  import type { LanguageAdapter } from "../types";
4
+ import { createPythonAdapter } from "./python";
4
5
  import { createTypeScriptAdapter } from "./typescript";
5
6
 
6
- const adapters: LanguageAdapter[] = [createTypeScriptAdapter()];
7
+ const adapters: LanguageAdapter[] = [createTypeScriptAdapter(), createPythonAdapter()];
7
8
 
8
9
  export function getLanguageAdapter(filePath: string) {
9
10
  const normalizedPath = path.normalize(filePath);
@@ -0,0 +1,264 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+
4
+ import type { LanguageAdapter, LanguageAnalysis } from "../types";
5
+
6
+ const PY_EXTENSIONS = new Set([".py", ".pyi"]);
7
+ const PYTHON_BINARIES = ["python3", "python"];
8
+
9
+ const PYTHON_ANALYZER_SCRIPT = String.raw`
10
+ import ast
11
+ import json
12
+ import os
13
+ import sys
14
+
15
+ source = sys.stdin.read()
16
+ file_path = sys.argv[1]
17
+ base_name = os.path.basename(file_path)
18
+
19
+
20
+ def is_public(name):
21
+ return isinstance(name, str) and len(name) > 0 and not name.startswith("_")
22
+
23
+
24
+ def extract_target_names(target):
25
+ if isinstance(target, ast.Name):
26
+ return [target.id]
27
+ if isinstance(target, (ast.Tuple, ast.List)):
28
+ names = []
29
+ for item in target.elts:
30
+ names.extend(extract_target_names(item))
31
+ return names
32
+ return []
33
+
34
+
35
+ def extract_string_sequence(node):
36
+ if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
37
+ values = []
38
+ for element in node.elts:
39
+ if not isinstance(element, ast.Constant) or not isinstance(element.value, str):
40
+ return None
41
+ values.append(element.value)
42
+ return values
43
+ return None
44
+
45
+
46
+ def is_main_guard(test):
47
+ if not isinstance(test, ast.Compare):
48
+ return False
49
+ if len(test.ops) != 1 or len(test.comparators) != 1:
50
+ return False
51
+ left = test.left
52
+ comparator = test.comparators[0]
53
+ return (
54
+ isinstance(left, ast.Name)
55
+ and left.id == "__name__"
56
+ and isinstance(test.ops[0], ast.Eq)
57
+ and isinstance(comparator, ast.Constant)
58
+ and comparator.value == "__main__"
59
+ )
60
+
61
+
62
+ def imported_name(alias):
63
+ return alias.asname or alias.name.split(".", 1)[0]
64
+
65
+
66
+ local_public = set()
67
+ imported_public = set()
68
+ explicit_all = None
69
+ has_wildcard_reexport = False
70
+ direct_reexport_count = 0
71
+ local_implementation_count = 0
72
+ uses_test_framework = False
73
+ has_main_entrypoint = False
74
+
75
+ try:
76
+ tree = ast.parse(source, filename=file_path)
77
+ except SyntaxError as exc:
78
+ message = f"{exc.msg} at line {exc.lineno}:{exc.offset}" if exc.lineno else exc.msg
79
+ sys.stderr.write(message)
80
+ sys.exit(2)
81
+
82
+ for node in tree.body:
83
+ if isinstance(node, ast.Import):
84
+ for alias in node.names:
85
+ imported = imported_name(alias)
86
+ if is_public(imported):
87
+ imported_public.add(imported)
88
+ if alias.name == "pytest" or alias.name.startswith("unittest"):
89
+ uses_test_framework = True
90
+ continue
91
+
92
+ if isinstance(node, ast.ImportFrom):
93
+ module = node.module or ""
94
+ if module == "pytest" or module.startswith("unittest"):
95
+ uses_test_framework = True
96
+ if any(alias.name == "*" for alias in node.names):
97
+ has_wildcard_reexport = True
98
+ direct_reexport_count += 1
99
+ continue
100
+ public_imports = 0
101
+ for alias in node.names:
102
+ imported = imported_name(alias)
103
+ if is_public(imported):
104
+ imported_public.add(imported)
105
+ public_imports += 1
106
+ if public_imports:
107
+ direct_reexport_count += 1
108
+ continue
109
+
110
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
111
+ local_implementation_count += 1
112
+ if node.name.startswith("test_"):
113
+ uses_test_framework = True
114
+ if is_public(node.name):
115
+ local_public.add(node.name)
116
+ continue
117
+
118
+ if isinstance(node, ast.ClassDef):
119
+ local_implementation_count += 1
120
+ if node.name.startswith("Test"):
121
+ uses_test_framework = True
122
+ if is_public(node.name):
123
+ local_public.add(node.name)
124
+ continue
125
+
126
+ if isinstance(node, ast.Assign):
127
+ for target in node.targets:
128
+ for name in extract_target_names(target):
129
+ if name == "__all__":
130
+ sequence = extract_string_sequence(node.value)
131
+ if sequence is not None:
132
+ explicit_all = sequence
133
+ continue
134
+ if is_public(name):
135
+ local_public.add(name)
136
+ local_implementation_count += 1
137
+ continue
138
+
139
+ if isinstance(node, ast.AnnAssign):
140
+ for name in extract_target_names(node.target):
141
+ if name == "__all__":
142
+ sequence = extract_string_sequence(node.value)
143
+ if sequence is not None:
144
+ explicit_all = sequence
145
+ continue
146
+ if is_public(name):
147
+ local_public.add(name)
148
+ local_implementation_count += 1
149
+ continue
150
+
151
+ if isinstance(node, ast.If) and is_main_guard(node.test):
152
+ has_main_entrypoint = True
153
+
154
+ if explicit_all is not None:
155
+ export_names = sorted({name for name in explicit_all if isinstance(name, str)})
156
+ export_confidence = "exact"
157
+ else:
158
+ export_names = set(local_public)
159
+ if base_name == "__init__.py":
160
+ export_names.update(imported_public)
161
+ export_names = sorted(export_names)
162
+ export_confidence = "heuristic"
163
+
164
+ local_export_count = sum(1 for name in export_names if name in local_public)
165
+
166
+ print(json.dumps({
167
+ "exports": export_names,
168
+ "valueExports": export_names,
169
+ "typeExports": [],
170
+ "exportConfidence": export_confidence,
171
+ "hasDefaultExport": False,
172
+ "hasWildcardReExport": has_wildcard_reexport,
173
+ "hasMainEntrypoint": has_main_entrypoint,
174
+ "directReExportCount": direct_reexport_count,
175
+ "localExportCount": local_export_count,
176
+ "localImplementationCount": local_implementation_count,
177
+ "usesTestFramework": uses_test_framework,
178
+ }))
179
+ `;
180
+
181
+ function createEmptyAnalysis(): LanguageAnalysis {
182
+ return {
183
+ adapterId: "python",
184
+ exports: new Set<string>(),
185
+ valueExports: new Set<string>(),
186
+ typeExports: new Set<string>(),
187
+ exportConfidence: "heuristic",
188
+ hasDefaultExport: false,
189
+ hasWildcardReExport: false,
190
+ hasMainEntrypoint: false,
191
+ directReExportCount: 0,
192
+ localExportCount: 0,
193
+ localImplementationCount: 0,
194
+ usesTestFramework: false,
195
+ };
196
+ }
197
+
198
+ function normalizeResult(output: string) {
199
+ const parsed = JSON.parse(output) as {
200
+ exports: string[];
201
+ valueExports: string[];
202
+ typeExports: string[];
203
+ exportConfidence: "exact" | "heuristic";
204
+ hasDefaultExport: boolean;
205
+ hasWildcardReExport: boolean;
206
+ hasMainEntrypoint: boolean;
207
+ directReExportCount: number;
208
+ localExportCount: number;
209
+ localImplementationCount: number;
210
+ usesTestFramework: boolean;
211
+ };
212
+
213
+ const analysis = createEmptyAnalysis();
214
+ analysis.exports = new Set(parsed.exports ?? []);
215
+ analysis.valueExports = new Set(parsed.valueExports ?? []);
216
+ analysis.typeExports = new Set(parsed.typeExports ?? []);
217
+ analysis.exportConfidence = parsed.exportConfidence ?? "heuristic";
218
+ analysis.hasDefaultExport = Boolean(parsed.hasDefaultExport);
219
+ analysis.hasWildcardReExport = Boolean(parsed.hasWildcardReExport);
220
+ analysis.hasMainEntrypoint = Boolean(parsed.hasMainEntrypoint);
221
+ analysis.directReExportCount = Number(parsed.directReExportCount ?? 0);
222
+ analysis.localExportCount = Number(parsed.localExportCount ?? 0);
223
+ analysis.localImplementationCount = Number(parsed.localImplementationCount ?? 0);
224
+ analysis.usesTestFramework = Boolean(parsed.usesTestFramework);
225
+ return analysis;
226
+ }
227
+
228
+ function runPythonAnalyzer(filePath: string, text: string) {
229
+ for (const binary of PYTHON_BINARIES) {
230
+ const run = spawnSync(binary, ["-c", PYTHON_ANALYZER_SCRIPT, filePath], {
231
+ input: text,
232
+ encoding: "utf8",
233
+ maxBuffer: 1024 * 1024,
234
+ });
235
+
236
+ if (run.error) {
237
+ const code = (run.error as NodeJS.ErrnoException).code;
238
+ if (code === "ENOENT") {
239
+ continue;
240
+ }
241
+ throw run.error;
242
+ }
243
+
244
+ if (run.status === 0) {
245
+ return normalizeResult(run.stdout);
246
+ }
247
+
248
+ throw new Error(run.stderr.trim() || run.stdout.trim() || `Python analyzer failed via ${binary}.`);
249
+ }
250
+
251
+ throw new Error("Python adapter requires `python3` or `python` on PATH when linting Python files.");
252
+ }
253
+
254
+ export function createPythonAdapter(): LanguageAdapter {
255
+ return {
256
+ id: "python",
257
+ supports(filePath) {
258
+ return PY_EXTENSIONS.has(path.extname(filePath));
259
+ },
260
+ analyze(filePath, text) {
261
+ return runPythonAnalyzer(filePath, text);
262
+ },
263
+ };
264
+ }
@@ -73,8 +73,10 @@ export function createTypeScriptAdapter(): LanguageAdapter {
73
73
  exports: new Set<string>(),
74
74
  valueExports: new Set<string>(),
75
75
  typeExports: new Set<string>(),
76
+ exportConfidence: "exact",
76
77
  hasDefaultExport: false,
77
78
  hasWildcardReExport: false,
79
+ hasMainEntrypoint: false,
78
80
  directReExportCount: 0,
79
81
  localExportCount: 0,
80
82
  localImplementationCount: 0,
package/src/lint/core.ts CHANGED
@@ -41,6 +41,7 @@ const CODE_EXTENSIONS = new Set([
41
41
  ".mts",
42
42
  ".cts",
43
43
  ".py",
44
+ ".pyi",
44
45
  ".go",
45
46
  ".java",
46
47
  ".kt",
@@ -432,6 +433,10 @@ function inferRole(contract: ModuleContractInfo | null, analysis: LanguageAnalys
432
433
  return "CONFIG";
433
434
  }
434
435
 
436
+ if (analysis?.hasMainEntrypoint) {
437
+ return "SCRIPT";
438
+ }
439
+
435
440
  if (analysis && mentionsScript && analysis.exports.size === 0) {
436
441
  return "SCRIPT";
437
442
  }
@@ -564,6 +569,17 @@ function lintExportMapParity(
564
569
  return;
565
570
  }
566
571
 
572
+ const exportSeverity = analysis.exportConfidence === "exact" ? "error" : "warning";
573
+
574
+ if (analysis.exportConfidence === "heuristic") {
575
+ addIssue(result, {
576
+ severity: "warning",
577
+ code: "analysis.heuristic-export-surface",
578
+ file: relativePath,
579
+ message: `The ${analysis.adapterId} adapter inferred exports heuristically for this file. Exact MODULE_MAP parity may require explicit file ROLE/MAP_MODE or stronger language-specific export declarations.`,
580
+ });
581
+ }
582
+
567
583
  if (analysis.hasWildcardReExport) {
568
584
  addIssue(result, {
569
585
  severity: "warning",
@@ -577,7 +593,7 @@ function lintExportMapParity(
577
593
  const mappedSymbols = new Set(items.flatMap((item) => (item.symbolName ? [item.symbolName] : [])));
578
594
  if (mappedSymbols.size === 0 && analysis.exports.size > 0) {
579
595
  addIssue(result, {
580
- severity: "error",
596
+ severity: exportSeverity,
581
597
  code: "markup.module-map-missing-symbol-entries",
582
598
  file: relativePath,
583
599
  message: "MODULE_MAP should list concrete symbol names when MAP_MODE resolves to EXPORTS.",
@@ -588,7 +604,7 @@ function lintExportMapParity(
588
604
  for (const exportName of analysis.exports) {
589
605
  if (!mappedSymbols.has(exportName)) {
590
606
  addIssue(result, {
591
- severity: "error",
607
+ severity: exportSeverity,
592
608
  code: "markup.module-map-missing-export",
593
609
  file: relativePath,
594
610
  message: `MODULE_MAP is missing the exported symbol \`${exportName}\`.`,
@@ -657,7 +673,19 @@ function lintGovernedFile(result: LintResult, root: string, filePath: string, te
657
673
  const contract = moduleContractSection ? parseModuleContract(moduleContractSection) : null;
658
674
  const mapItems = moduleMapSection ? parseModuleMapItems(moduleMapSection) : [];
659
675
  const adapter = getLanguageAdapter(filePath);
660
- const analysis = adapter ? adapter.analyze(filePath, text) : null;
676
+ let analysis: LanguageAnalysis | null = null;
677
+ if (adapter) {
678
+ try {
679
+ analysis = adapter.analyze(filePath, text);
680
+ } catch (error) {
681
+ addIssue(result, {
682
+ severity: "warning",
683
+ code: "analysis.adapter-failed",
684
+ file: relativePath,
685
+ message: `${adapter.id} adapter failed: ${error instanceof Error ? error.message : String(error)}`,
686
+ });
687
+ }
688
+ }
661
689
  const role = inferRole(contract, analysis);
662
690
  const mapMode = inferMapMode(contract, role, mapItems, analysis);
663
691
 
package/src/lint/types.ts CHANGED
@@ -54,8 +54,10 @@ export type LanguageAnalysis = {
54
54
  exports: Set<string>;
55
55
  valueExports: Set<string>;
56
56
  typeExports: Set<string>;
57
+ exportConfidence: "exact" | "heuristic";
57
58
  hasDefaultExport: boolean;
58
59
  hasWildcardReExport: boolean;
60
+ hasMainEntrypoint: boolean;
59
61
  directReExportCount: number;
60
62
  localExportCount: number;
61
63
  localImplementationCount: number;