@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 +15 -4
- package/package.json +3 -1
- package/src/grace.ts +1 -1
- package/src/lint/adapters/base.ts +2 -1
- package/src/lint/adapters/python.ts +264 -0
- package/src/lint/adapters/typescript.ts +2 -0
- package/src/lint/core.ts +31 -3
- package/src/lint/types.ts +2 -0
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.
|
|
13
|
+
Current packaged version: `3.4.0`
|
|
14
14
|
|
|
15
15
|
## What Changed In This Version
|
|
16
16
|
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
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
|
+
"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
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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;
|