@jterrazz/intelligence 4.2.0 → 6.0.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 +233 -217
- package/dist/formatting.cjs +2845 -0
- package/dist/formatting.cjs.map +1 -0
- package/dist/formatting.d.cts +105 -0
- package/dist/formatting.d.ts +105 -0
- package/dist/formatting.js +2819 -0
- package/dist/formatting.js.map +1 -0
- package/dist/index.cjs +683 -476
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +183 -1396
- package/dist/index.d.ts +183 -1396
- package/dist/index.js +680 -459
- package/dist/index.js.map +1 -1
- package/dist/oxlint.cjs +629 -0
- package/dist/oxlint.cjs.map +1 -0
- package/dist/oxlint.d.cts +132 -0
- package/dist/oxlint.d.ts +132 -0
- package/dist/oxlint.js +623 -0
- package/dist/oxlint.js.map +1 -0
- package/package.json +28 -22
- package/dist/parse-text.cjs +0 -57
- package/dist/parse-text.cjs.map +0 -1
- package/dist/parse-text.d.cts +0 -18
- package/dist/parse-text.d.ts +0 -18
- package/dist/parse-text.js +0 -52
- package/dist/parse-text.js.map +0 -1
- package/dist/text.cjs +0 -3
- package/dist/text.d.cts +0 -2
- package/dist/text.d.ts +0 -2
- package/dist/text.js +0 -2
package/dist/oxlint.js
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
//#region src/lint/ast.ts
|
|
4
|
+
/**
|
|
5
|
+
* Shared AST helpers for the rule files. Everything here is pure and
|
|
6
|
+
* structural: rules narrow nodes by `type` and read fields defensively, so the
|
|
7
|
+
* layer stays decoupled from oxlint's internal (alpha) typings (mirrors
|
|
8
|
+
* `@jterrazz/test`'s `src/lint/ast.ts`).
|
|
9
|
+
*/
|
|
10
|
+
/** Split a path into its non-empty segments (posix or win separators). */
|
|
11
|
+
function segments(path) {
|
|
12
|
+
return path.split(/[/\\]/).filter(Boolean);
|
|
13
|
+
}
|
|
14
|
+
/** The string value of a plain string literal (or a template with no holes). */
|
|
15
|
+
function stringValue(node) {
|
|
16
|
+
if (node === void 0) return;
|
|
17
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
18
|
+
if (node.type === "TemplateLiteral") {
|
|
19
|
+
const expressions = node.expressions;
|
|
20
|
+
const quasis = node.quasis;
|
|
21
|
+
if (expressions?.length === 0 && quasis?.length === 1) return quasis[0].value?.cooked;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** The property name of a non-computed member expression, if identifiable. */
|
|
25
|
+
function memberPropertyName(node) {
|
|
26
|
+
if (node.type !== "MemberExpression" || node.computed === true) return;
|
|
27
|
+
const property = node.property;
|
|
28
|
+
return property?.type === "Identifier" ? property.name : void 0;
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/lint/agents.ts
|
|
32
|
+
/**
|
|
33
|
+
* Is `filePath` under a folder literally named `agents` (exact path segment,
|
|
34
|
+
* anywhere in the path — P1's broader scope, unlike {@link detectAgentFile}'s
|
|
35
|
+
* narrower `<name>/<name>.ts` shape)?
|
|
36
|
+
*/
|
|
37
|
+
function isUnderAgentsFolder(filePath) {
|
|
38
|
+
return segments(filePath).includes("agents");
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* `agents/<name>/<name>.ts` — the file basename (minus `.ts`) must equal its
|
|
42
|
+
* parent directory name, and an `agents` segment must be a proper ancestor of
|
|
43
|
+
* that parent directory. Returns `undefined` for anything else, including
|
|
44
|
+
* `*.prompt.ts` and `*.test.ts` (never agent files).
|
|
45
|
+
*/
|
|
46
|
+
function detectAgentFile(filePath) {
|
|
47
|
+
const parts = segments(filePath);
|
|
48
|
+
const base = parts.at(-1);
|
|
49
|
+
if (base === void 0 || !base.endsWith(".ts")) return;
|
|
50
|
+
if (base.endsWith(".prompt.ts") || base.endsWith(".test.ts")) return;
|
|
51
|
+
const stem = base.slice(0, -3);
|
|
52
|
+
const parentIndex = parts.length - 2;
|
|
53
|
+
if (parts[parentIndex] !== stem) return;
|
|
54
|
+
const agentsIndex = parts.lastIndexOf("agents");
|
|
55
|
+
if (agentsIndex === -1 || agentsIndex >= parentIndex) return;
|
|
56
|
+
return { name: stem };
|
|
57
|
+
}
|
|
58
|
+
/** `<name>.prompt.ts` — any directory; `shared` is true directly under `_shared/`. */
|
|
59
|
+
function detectPromptFile(filePath) {
|
|
60
|
+
const parts = segments(filePath);
|
|
61
|
+
const base = parts.at(-1);
|
|
62
|
+
if (base === void 0 || !base.endsWith(".prompt.ts")) return;
|
|
63
|
+
const stem = base.slice(0, -10);
|
|
64
|
+
return {
|
|
65
|
+
dir: dirname(filePath),
|
|
66
|
+
name: stem,
|
|
67
|
+
shared: parts.at(-2) === "_shared"
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/lint/manifest.ts
|
|
72
|
+
/**
|
|
73
|
+
* The rule manifest — the single source of truth for the mechanized
|
|
74
|
+
* agent/prompt conventions catalogue (mirrors `@jterrazz/test`'s
|
|
75
|
+
* `src/lint/manifest.ts` docs-as-code inversion: the normative text lives
|
|
76
|
+
* NEXT TO the rule it documents, attached as `meta.docs`, instead of drifting
|
|
77
|
+
* apart in a hand-maintained doc).
|
|
78
|
+
*
|
|
79
|
+
* The README's "Agent & prompt conventions" section is the human-facing
|
|
80
|
+
* explanation of *why* this shape exists; this manifest is the machine-facing
|
|
81
|
+
* "what exactly is checked" — `plugin.test.ts` asserts every shipped rule
|
|
82
|
+
* carries its entry and that no entry is orphaned.
|
|
83
|
+
*/
|
|
84
|
+
const RULE_DOCS = {
|
|
85
|
+
"g1-agent-class-shape": {
|
|
86
|
+
id: "G1",
|
|
87
|
+
convention: "An agent file (`agents/<name>/<name>.ts`) exports exactly one class; that class has a `static readonly SCHEMA` member, a `run` method, and a constructor whose first parameter is named `model`.",
|
|
88
|
+
rationale: "A fixed shape makes every agent class predictable to read and to wire up from a DI container without re-deriving its contract each time."
|
|
89
|
+
},
|
|
90
|
+
"m1-model-resolution-in-container": {
|
|
91
|
+
id: "M1",
|
|
92
|
+
convention: "Calls to `createIntelligence(...)`, `createGatewayProvider(...)`, `createOpenRouterProvider(...)`, and `.model('…')` on the value they produce, are only allowed in a file whose path contains `/di/` or whose name ends in `container.ts`.",
|
|
93
|
+
rationale: "Model resolution is composition-root work — scattering it lets a provider/model choice drift outside the one place meant to own it."
|
|
94
|
+
},
|
|
95
|
+
"m2w-no-hardcoded-model-id": {
|
|
96
|
+
id: "M2",
|
|
97
|
+
convention: "A string literal that looks like a model id (`claude-…`, `openai/gpt-…`, …) outside config/test/fixture/spec files is a warning — model ids belong in configuration.",
|
|
98
|
+
rationale: "A model id inlined in application code can only change by a code deploy; configuration lets it change without one."
|
|
99
|
+
},
|
|
100
|
+
"p1-prose-in-prompt-files": {
|
|
101
|
+
id: "P1",
|
|
102
|
+
convention: "In any file under an `agents/` folder that is not a `*.prompt.ts` (nor a `*.test.ts`), a template literal spanning 3+ lines that reads as natural-language prose (a line with 4+ space-separated words, or a markdown heading) is an error — move it to the sibling `*.prompt.ts`.",
|
|
103
|
+
rationale: "The agent class only shapes data; prose that leaks into it hides the actual prompt contract and makes the two impossible to review independently."
|
|
104
|
+
},
|
|
105
|
+
"p2-prompt-file-exports": {
|
|
106
|
+
id: "P2",
|
|
107
|
+
convention: "A `*.prompt.ts` file exports only const arrow functions returning a string, plus types/interfaces — no default export, no class, no non-function const.",
|
|
108
|
+
rationale: "A closed export surface keeps a prompt file a pure builder module — anything else (state, a class, a default export) would invite prompt logic to grow side effects."
|
|
109
|
+
},
|
|
110
|
+
"p3-agent-prompt-sibling": {
|
|
111
|
+
id: "P3",
|
|
112
|
+
convention: "An agent file (`agents/<name>/<name>.ts`) imports its prompt from `./<name>.prompt.js`; a `<name>.prompt.ts` file outside `_shared/` has a sibling `<name>.ts` in the same directory.",
|
|
113
|
+
rationale: "The two-way link is what makes the pairing mechanical instead of a naming convention nobody enforces — an orphaned prompt file, or an agent that silently doesn't use its prompt, is almost always a mistake."
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/lint/rules/g1-agent-class-shape.ts
|
|
118
|
+
/** Unwrap `private readonly model: T` (TSParameterProperty) / defaults to the bare identifier. */
|
|
119
|
+
function parameterName(param) {
|
|
120
|
+
if (param === void 0) return;
|
|
121
|
+
if (param.type === "Identifier") return param.name;
|
|
122
|
+
if (param.type === "TSParameterProperty") return parameterName(param.parameter);
|
|
123
|
+
if (param.type === "AssignmentPattern") return parameterName(param.left);
|
|
124
|
+
}
|
|
125
|
+
function classBody(classNode) {
|
|
126
|
+
return classNode.body?.body ?? [];
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* CONVENTIONS G1 — an agent file's class shape: exactly one exported class,
|
|
130
|
+
* carrying a `static readonly SCHEMA` member, a `run` method, and a
|
|
131
|
+
* constructor whose first parameter is `model` (the `LanguageModel` the
|
|
132
|
+
* class's `run()` passes straight to `generateText`/`streamText`).
|
|
133
|
+
*/
|
|
134
|
+
const g1AgentClassShape = {
|
|
135
|
+
create(context) {
|
|
136
|
+
const file = context.physicalFilename;
|
|
137
|
+
if (detectAgentFile(file) === void 0) return {};
|
|
138
|
+
const exportedClasses = [];
|
|
139
|
+
return {
|
|
140
|
+
ExportDefaultDeclaration(node) {
|
|
141
|
+
const declaration = node.declaration;
|
|
142
|
+
if (declaration?.type === "ClassDeclaration" || declaration?.type === "ClassExpression") exportedClasses.push(declaration);
|
|
143
|
+
},
|
|
144
|
+
ExportNamedDeclaration(node) {
|
|
145
|
+
const declaration = node.declaration;
|
|
146
|
+
if (declaration?.type === "ClassDeclaration") exportedClasses.push(declaration);
|
|
147
|
+
},
|
|
148
|
+
"Program:exit"(node) {
|
|
149
|
+
if (exportedClasses.length === 0) {
|
|
150
|
+
context.report({
|
|
151
|
+
messageId: "noExportedClass",
|
|
152
|
+
node
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (exportedClasses.length > 1) for (const extra of exportedClasses.slice(1)) context.report({
|
|
157
|
+
messageId: "multipleExportedClasses",
|
|
158
|
+
node: extra
|
|
159
|
+
});
|
|
160
|
+
const target = exportedClasses[0];
|
|
161
|
+
const members = classBody(target);
|
|
162
|
+
if (!members.some((member) => {
|
|
163
|
+
if (member.type !== "PropertyDefinition" || member.static !== true) return false;
|
|
164
|
+
const key = member.key;
|
|
165
|
+
return key?.type === "Identifier" && key.name === "SCHEMA";
|
|
166
|
+
})) context.report({
|
|
167
|
+
messageId: "missingSchema",
|
|
168
|
+
node: target
|
|
169
|
+
});
|
|
170
|
+
if (!members.some((member) => {
|
|
171
|
+
if (member.type !== "MethodDefinition" && member.type !== "PropertyDefinition") return false;
|
|
172
|
+
const key = member.key;
|
|
173
|
+
return key?.type === "Identifier" && key.name === "run";
|
|
174
|
+
})) context.report({
|
|
175
|
+
messageId: "missingRun",
|
|
176
|
+
node: target
|
|
177
|
+
});
|
|
178
|
+
const constructor = members.find((member) => member.type === "MethodDefinition" && (member.kind === "constructor" || member.key?.name === "constructor"));
|
|
179
|
+
if (constructor === void 0) {
|
|
180
|
+
context.report({
|
|
181
|
+
messageId: "missingConstructorModel",
|
|
182
|
+
node: target
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const firstParam = (constructor.value?.params)?.[0];
|
|
187
|
+
if (parameterName(firstParam) !== "model") context.report({
|
|
188
|
+
messageId: "missingConstructorModel",
|
|
189
|
+
node: firstParam ?? constructor
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
meta: {
|
|
195
|
+
docs: RULE_DOCS["g1-agent-class-shape"],
|
|
196
|
+
messages: {
|
|
197
|
+
missingConstructorModel: "Agent class constructor's first parameter must be named `model` (G1).",
|
|
198
|
+
missingRun: "Agent class is missing a `run` method (G1).",
|
|
199
|
+
missingSchema: "Agent class is missing a `static readonly SCHEMA` member (G1).",
|
|
200
|
+
multipleExportedClasses: "Agent file exports more than one class — exactly one (G1).",
|
|
201
|
+
noExportedClass: "Agent file exports no class — exactly one is required (G1)."
|
|
202
|
+
},
|
|
203
|
+
type: "problem"
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region src/lint/rules/m1-model-resolution-in-container.ts
|
|
208
|
+
/** The composition-root factories `@jterrazz/intelligence` exposes. */
|
|
209
|
+
const FACTORY_NAMES = /* @__PURE__ */ new Set([
|
|
210
|
+
"createGatewayProvider",
|
|
211
|
+
"createIntelligence",
|
|
212
|
+
"createOpenRouterProvider"
|
|
213
|
+
]);
|
|
214
|
+
/** Is `file` a DI/composition-root file? */
|
|
215
|
+
function isContainerFile(file) {
|
|
216
|
+
return file.includes("/di/") || file.endsWith("container.ts");
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* CONVENTIONS M1 — model resolution is composition-root work. `createIntelligence`
|
|
220
|
+
* and the two provider factories, plus `.model('…')` calls on whatever they
|
|
221
|
+
* produce, are only allowed in a file under `di/` or named `*container.ts`.
|
|
222
|
+
*
|
|
223
|
+
* Detection is best effort by design: a `.model(<string literal>)` call is
|
|
224
|
+
* flagged wherever it appears in a file that imports `@jterrazz/intelligence`,
|
|
225
|
+
* WITHOUT verifying the receiver is actually the `Intelligence` instance —
|
|
226
|
+
* static analysis cannot reliably trace that binding across parameter
|
|
227
|
+
* passing/destructuring (see the container.ts example in the README, where
|
|
228
|
+
* `Intelligence` arrives as an injected parameter, not a local `const`). A
|
|
229
|
+
* project with an unrelated `.model()` method on some other object, imported
|
|
230
|
+
* from the same file as `@jterrazz/intelligence`, would false-positive here —
|
|
231
|
+
* an accepted, documented limitation of this rule.
|
|
232
|
+
*/
|
|
233
|
+
const m1ModelResolutionInContainer = {
|
|
234
|
+
create(context) {
|
|
235
|
+
const file = context.physicalFilename;
|
|
236
|
+
const allowed = isContainerFile(file);
|
|
237
|
+
let importsIntelligence = false;
|
|
238
|
+
const modelCalls = [];
|
|
239
|
+
const factoryCalls = [];
|
|
240
|
+
return {
|
|
241
|
+
CallExpression(node) {
|
|
242
|
+
const callee = node.callee;
|
|
243
|
+
if (callee === void 0) return;
|
|
244
|
+
if (callee.type === "Identifier" && FACTORY_NAMES.has(callee.name)) {
|
|
245
|
+
factoryCalls.push({
|
|
246
|
+
name: callee.name,
|
|
247
|
+
node
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (callee.type === "MemberExpression" && memberPropertyName(callee) === "model") {
|
|
252
|
+
const args = node.arguments ?? [];
|
|
253
|
+
if (args.length === 1 && stringValue(args[0]) !== void 0) modelCalls.push(node);
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
ImportDeclaration(node) {
|
|
257
|
+
if (stringValue(node.source) === "@jterrazz/intelligence") importsIntelligence = true;
|
|
258
|
+
},
|
|
259
|
+
"Program:exit"() {
|
|
260
|
+
if (allowed) return;
|
|
261
|
+
for (const { name, node } of factoryCalls) context.report({
|
|
262
|
+
data: { name },
|
|
263
|
+
messageId: "factoryOutsideContainer",
|
|
264
|
+
node
|
|
265
|
+
});
|
|
266
|
+
if (importsIntelligence) for (const node of modelCalls) context.report({
|
|
267
|
+
messageId: "modelCallOutsideContainer",
|
|
268
|
+
node
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
meta: {
|
|
274
|
+
docs: RULE_DOCS["m1-model-resolution-in-container"],
|
|
275
|
+
messages: {
|
|
276
|
+
factoryOutsideContainer: "{{name}}() must only be called from a DI/container file (path containing \"/di/\" or ending in \"container.ts\") (M1).",
|
|
277
|
+
modelCallOutsideContainer: ".model('…') resolution must only happen in a DI/container file (M1)."
|
|
278
|
+
},
|
|
279
|
+
type: "problem"
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
//#endregion
|
|
283
|
+
//#region src/lint/rules/m2w-no-hardcoded-model-id.ts
|
|
284
|
+
/** `claude-…`, `gpt-4o`, `o1-preview`, `grok-2`, `deepseek-v3`, … */
|
|
285
|
+
const BARE_MODEL_ID = /^(?<family>claude|deepseek|gemini|gpt|grok|llama|mistral|o[0-9])[-0-9a-z.]/iu;
|
|
286
|
+
/** `openai/gpt-4o`, `anthropic/claude-3-5-sonnet`, … (provider-prefixed form). */
|
|
287
|
+
const PREFIXED_MODEL_ID = /^[a-z0-9-]+\/(?<family>claude|deepseek|gemini|gpt|grok|llama|mistral)/iu;
|
|
288
|
+
/** Config/test/fixture/spec files are exempt — that's exactly where a model id belongs. */
|
|
289
|
+
function isExemptFile(file) {
|
|
290
|
+
const parts = segments(file);
|
|
291
|
+
const base = parts.at(-1) ?? "";
|
|
292
|
+
if (/\.(?:test|spec)\.[cm]?tsx?$/u.test(base)) return true;
|
|
293
|
+
if (parts.includes("fixtures") || parts.includes("specs") || parts.includes("__fixtures__")) return true;
|
|
294
|
+
return parts.includes("config") || /\.config\.[cm]?tsx?$/u.test(base);
|
|
295
|
+
}
|
|
296
|
+
function looksLikeModelId(value) {
|
|
297
|
+
return BARE_MODEL_ID.test(value) || PREFIXED_MODEL_ID.test(value);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* CONVENTIONS M2 (warning) — a string literal shaped like a model id belongs
|
|
301
|
+
* in configuration, not inlined in application code. Config/test/fixture/spec
|
|
302
|
+
* files are exempt by design (oxlint only ever visits `.ts`/`.tsx` sources —
|
|
303
|
+
* a `.yml`/`.json` config value is never in its reach regardless).
|
|
304
|
+
*/
|
|
305
|
+
const m2wNoHardcodedModelId = {
|
|
306
|
+
create(context) {
|
|
307
|
+
const file = context.physicalFilename;
|
|
308
|
+
if (isExemptFile(file)) return {};
|
|
309
|
+
return { Literal(node) {
|
|
310
|
+
if (typeof node.value !== "string") return;
|
|
311
|
+
if (looksLikeModelId(node.value)) context.report({
|
|
312
|
+
data: { value: node.value },
|
|
313
|
+
messageId: "hardcodedModelId",
|
|
314
|
+
node
|
|
315
|
+
});
|
|
316
|
+
} };
|
|
317
|
+
},
|
|
318
|
+
meta: {
|
|
319
|
+
docs: RULE_DOCS["m2w-no-hardcoded-model-id"],
|
|
320
|
+
messages: { hardcodedModelId: "String \"{{value}}\" looks like a model id — model ids belong in configuration (M2)." },
|
|
321
|
+
type: "suggestion"
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
//#endregion
|
|
325
|
+
//#region src/lint/rules/p1-prose-in-prompt-files.ts
|
|
326
|
+
/** A markdown heading — `#`, `##`, or `###` followed by a space. */
|
|
327
|
+
const MARKDOWN_HEADING = /^#{1,3}\s/;
|
|
328
|
+
/** A line reading as prose: 4+ tokens separated by whitespace. */
|
|
329
|
+
const MIN_PROSE_WORDS = 4;
|
|
330
|
+
/** Does `text` (one physical line) look like natural-language prose? */
|
|
331
|
+
function looksLikeProseLine(line) {
|
|
332
|
+
const trimmed = line.trim();
|
|
333
|
+
if (trimmed.length === 0) return false;
|
|
334
|
+
if (MARKDOWN_HEADING.test(trimmed)) return true;
|
|
335
|
+
return trimmed.split(/\s+/u).filter(Boolean).length >= MIN_PROSE_WORDS;
|
|
336
|
+
}
|
|
337
|
+
/** Number of physical lines a source span covers, from a `\n` count. */
|
|
338
|
+
function lineSpan(text) {
|
|
339
|
+
return text.split("\n").length;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* CONVENTIONS P1 — the flagship rule: no multi-line natural-language literal
|
|
343
|
+
* outside a `*.prompt.ts` file. A template literal is flagged when its full
|
|
344
|
+
* source span reaches 3+ lines AND at least one of its own static quasis
|
|
345
|
+
* (never the interpolated expressions) reads as prose — a line with 4+
|
|
346
|
+
* space-separated words, or a markdown heading. Single-line literals, JSON-ish
|
|
347
|
+
* multi-line literals, and pure data interpolation stay under the threshold
|
|
348
|
+
* and pass.
|
|
349
|
+
*/
|
|
350
|
+
const p1ProseInPromptFiles = {
|
|
351
|
+
create(context) {
|
|
352
|
+
const file = context.physicalFilename;
|
|
353
|
+
if (!isUnderAgentsFolder(file)) return {};
|
|
354
|
+
const base = file.split(/[/\\]/).pop() ?? "";
|
|
355
|
+
if (base.endsWith(".prompt.ts") || base.endsWith(".test.ts")) return {};
|
|
356
|
+
const target = `${base.replace(/\.ts$/, "")}.prompt.ts`;
|
|
357
|
+
return { TemplateLiteral(node) {
|
|
358
|
+
const start = node.start ?? node.range?.[0];
|
|
359
|
+
const end = node.end ?? node.range?.[1];
|
|
360
|
+
if (typeof start !== "number" || typeof end !== "number") return;
|
|
361
|
+
if (lineSpan(context.sourceCode.text.slice(start, end)) < 3) return;
|
|
362
|
+
if ((node.quasis ?? []).some((quasi) => {
|
|
363
|
+
return (quasi.value?.raw ?? "").split("\n").some(looksLikeProseLine);
|
|
364
|
+
})) context.report({
|
|
365
|
+
data: { target },
|
|
366
|
+
messageId: "moveProse",
|
|
367
|
+
node
|
|
368
|
+
});
|
|
369
|
+
} };
|
|
370
|
+
},
|
|
371
|
+
meta: {
|
|
372
|
+
docs: RULE_DOCS["p1-prose-in-prompt-files"],
|
|
373
|
+
messages: { moveProse: "Multi-line natural-language template literal outside a *.prompt.ts file — move prompt prose to {{target}} (P1)." },
|
|
374
|
+
type: "problem"
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
//#endregion
|
|
378
|
+
//#region src/lint/rules/p2-prompt-file-exports.ts
|
|
379
|
+
/** Declaration types that are types, not values — always allowed. */
|
|
380
|
+
const TYPE_DECLARATIONS = /* @__PURE__ */ new Set(["TSInterfaceDeclaration", "TSTypeAliasDeclaration"]);
|
|
381
|
+
/**
|
|
382
|
+
* Best-effort "does this expression plausibly evaluate to a string?" check.
|
|
383
|
+
* Permissive by design (P2 is a shape gate, not a type checker): a delegated
|
|
384
|
+
* call (`return sharedSection();`), a bare identifier, member access, string
|
|
385
|
+
* concatenation, and `? :` / `||` narrowing all pass. Only expressions that
|
|
386
|
+
* are CLEARLY the wrong shape — an object/array literal, a non-string literal,
|
|
387
|
+
* a nested function — are rejected.
|
|
388
|
+
*/
|
|
389
|
+
function looksLikeStringExpression(node) {
|
|
390
|
+
if (node === void 0) return false;
|
|
391
|
+
switch (node.type) {
|
|
392
|
+
case "BinaryExpression": return node.operator === "+";
|
|
393
|
+
case "CallExpression":
|
|
394
|
+
case "Identifier":
|
|
395
|
+
case "MemberExpression":
|
|
396
|
+
case "TemplateLiteral": return true;
|
|
397
|
+
case "ConditionalExpression": return looksLikeStringExpression(node.consequent) && looksLikeStringExpression(node.alternate);
|
|
398
|
+
case "Literal": return typeof node.value === "string";
|
|
399
|
+
case "LogicalExpression": return looksLikeStringExpression(node.right);
|
|
400
|
+
default: return false;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* The expressions `fn` itself returns — the concise-arrow expression body, or
|
|
405
|
+
* every top-level `return`'s argument in a block body (never descending into a
|
|
406
|
+
* nested function's own returns). Each is a REAL AST node (unlike a synthetic
|
|
407
|
+
* `ReturnStatement` wrapper), so it always carries a valid position to report on.
|
|
408
|
+
*/
|
|
409
|
+
function ownReturnExpressions(fn) {
|
|
410
|
+
const body = fn.body;
|
|
411
|
+
if (body === void 0) return [];
|
|
412
|
+
if (body.type !== "BlockStatement") return [body];
|
|
413
|
+
const expressions = [];
|
|
414
|
+
const visit = (node) => {
|
|
415
|
+
if (node === void 0) return;
|
|
416
|
+
if (node.type === "ReturnStatement") {
|
|
417
|
+
const argument = node.argument;
|
|
418
|
+
if (argument !== void 0) expressions.push(argument);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression" || node.type === "FunctionDeclaration") return;
|
|
422
|
+
for (const key of Object.keys(node)) {
|
|
423
|
+
if (key === "parent") continue;
|
|
424
|
+
const value = node[key];
|
|
425
|
+
if (Array.isArray(value)) {
|
|
426
|
+
for (const item of value) if (isNode(item)) visit(item);
|
|
427
|
+
} else if (isNode(value)) visit(value);
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
visit(body);
|
|
431
|
+
return expressions;
|
|
432
|
+
}
|
|
433
|
+
function isNode(value) {
|
|
434
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* CONVENTIONS P2 — a `*.prompt.ts` file's export surface is closed: const
|
|
438
|
+
* arrow functions returning a string (the builders), and types/interfaces.
|
|
439
|
+
* No default export, no class, no non-function const, no plain `function`
|
|
440
|
+
* declaration (the convention is arrow consts specifically — see the
|
|
441
|
+
* README's "Agent & prompt conventions" section).
|
|
442
|
+
*/
|
|
443
|
+
const p2PromptFileExports = {
|
|
444
|
+
create(context) {
|
|
445
|
+
if (!context.physicalFilename.endsWith(".prompt.ts")) return {};
|
|
446
|
+
return { Program(node) {
|
|
447
|
+
for (const statement of node.body ?? []) {
|
|
448
|
+
if (statement.type === "ExportDefaultDeclaration") {
|
|
449
|
+
context.report({
|
|
450
|
+
messageId: "defaultExport",
|
|
451
|
+
node: statement
|
|
452
|
+
});
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (statement.type !== "ExportNamedDeclaration") continue;
|
|
456
|
+
const declaration = statement.declaration;
|
|
457
|
+
if (declaration === void 0) continue;
|
|
458
|
+
if (TYPE_DECLARATIONS.has(declaration.type)) continue;
|
|
459
|
+
if (declaration.type === "ClassDeclaration") {
|
|
460
|
+
context.report({
|
|
461
|
+
messageId: "classExport",
|
|
462
|
+
node: statement
|
|
463
|
+
});
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (declaration.type !== "VariableDeclaration") {
|
|
467
|
+
const declId = declaration.id;
|
|
468
|
+
const declName = declId?.type === "Identifier" ? declId.name : "?";
|
|
469
|
+
context.report({
|
|
470
|
+
data: { name: declName },
|
|
471
|
+
messageId: "nonFunctionExport",
|
|
472
|
+
node: statement
|
|
473
|
+
});
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
for (const declarator of declaration.declarations ?? []) {
|
|
477
|
+
const id = declarator.id;
|
|
478
|
+
const name = id?.type === "Identifier" ? id.name : "?";
|
|
479
|
+
const init = declarator.init;
|
|
480
|
+
if (init?.type !== "ArrowFunctionExpression") {
|
|
481
|
+
context.report({
|
|
482
|
+
data: { name },
|
|
483
|
+
messageId: "nonFunctionExport",
|
|
484
|
+
node: declarator
|
|
485
|
+
});
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const returnExpressions = ownReturnExpressions(init);
|
|
489
|
+
const badReturn = returnExpressions.length === 0 ? init : returnExpressions.find((expression) => !looksLikeStringExpression(expression));
|
|
490
|
+
if (badReturn !== void 0) context.report({
|
|
491
|
+
data: { name },
|
|
492
|
+
messageId: "nonStringReturn",
|
|
493
|
+
node: badReturn
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
} };
|
|
498
|
+
},
|
|
499
|
+
meta: {
|
|
500
|
+
docs: RULE_DOCS["p2-prompt-file-exports"],
|
|
501
|
+
messages: {
|
|
502
|
+
classExport: "Prompt file exports a class — a *.prompt.ts file exports only const string-builder functions and types (P2).",
|
|
503
|
+
defaultExport: "Prompt file has a default export — a *.prompt.ts file exports only const string-builder functions and types (P2).",
|
|
504
|
+
nonFunctionExport: "Prompt file export \"{{name}}\" is not a const arrow function — a *.prompt.ts file exports only const string-builder functions and types (P2).",
|
|
505
|
+
nonStringReturn: "Prompt file export \"{{name}}\" does not appear to return a string (P2)."
|
|
506
|
+
},
|
|
507
|
+
type: "problem"
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
//#endregion
|
|
511
|
+
//#region src/lint/fs.ts
|
|
512
|
+
/**
|
|
513
|
+
* Filesystem probe for the one fs-anchored rule (P3 — the sibling check).
|
|
514
|
+
* Mirrors `@jterrazz/test`'s `src/lint/fs-cache.ts` in spirit, minus the
|
|
515
|
+
* memoization: P3 does at most one `existsSync` per visited file, so a cache
|
|
516
|
+
* would add complexity without a measurable payoff at this plugin's size.
|
|
517
|
+
*/
|
|
518
|
+
function fileExists(path) {
|
|
519
|
+
try {
|
|
520
|
+
return existsSync(path);
|
|
521
|
+
} catch {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region src/lint/plugin.ts
|
|
527
|
+
/**
|
|
528
|
+
* The `@jterrazz/intelligence` oxlint plugin — formalizes the agent/prompt
|
|
529
|
+
* folder convention documented in the README's "Agent & prompt conventions"
|
|
530
|
+
* section as statically-checkable rules, mirroring `@jterrazz/test`'s
|
|
531
|
+
* `src/lint/plugin.ts` (same composable-fragment architecture, same
|
|
532
|
+
* `RuleTester` test layer, same manifest/docs-as-code pattern).
|
|
533
|
+
*
|
|
534
|
+
* Registered in a consumer's `oxlint.config.ts` via
|
|
535
|
+
* `jsPlugins: ['@jterrazz/intelligence/oxlint']` and referenced as
|
|
536
|
+
* `intelligence/<rule>` in the `rules` map — or enabled wholesale via the
|
|
537
|
+
* {@link intelligence} composable fragment:
|
|
538
|
+
*
|
|
539
|
+
* import { compose, node } from '@jterrazz/typescript/oxlint';
|
|
540
|
+
* import { intelligence } from '@jterrazz/intelligence/oxlint';
|
|
541
|
+
* export default compose(node, intelligence);
|
|
542
|
+
*
|
|
543
|
+
* Bundled by tsdown (`dist/oxlint.js`); rules import nothing from this
|
|
544
|
+
* package's AI SDK runtime (only pure structural helpers: `ast.ts`, `fs.ts`,
|
|
545
|
+
* `agents.ts`), so the bundle stays free of the `ai`/`@ai-sdk/*` dependency
|
|
546
|
+
* graph the main entry pulls in.
|
|
547
|
+
*/
|
|
548
|
+
const plugin = {
|
|
549
|
+
meta: { name: "intelligence" },
|
|
550
|
+
rules: {
|
|
551
|
+
"g1-agent-class-shape": g1AgentClassShape,
|
|
552
|
+
"m1-model-resolution-in-container": m1ModelResolutionInContainer,
|
|
553
|
+
"m2w-no-hardcoded-model-id": m2wNoHardcodedModelId,
|
|
554
|
+
"p1-prose-in-prompt-files": p1ProseInPromptFiles,
|
|
555
|
+
"p2-prompt-file-exports": p2PromptFileExports,
|
|
556
|
+
"p3-agent-prompt-sibling": {
|
|
557
|
+
create(context) {
|
|
558
|
+
const file = context.physicalFilename;
|
|
559
|
+
const agent = detectAgentFile(file);
|
|
560
|
+
const prompt = agent === void 0 ? detectPromptFile(file) : void 0;
|
|
561
|
+
if (agent === void 0 && prompt === void 0) return {};
|
|
562
|
+
return { Program(node) {
|
|
563
|
+
if (agent !== void 0) {
|
|
564
|
+
const expected = `./${agent.name}.prompt.js`;
|
|
565
|
+
if (!(node.body ?? []).some((statement) => statement.type === "ImportDeclaration" && stringValue(statement.source) === expected)) context.report({
|
|
566
|
+
data: { expected },
|
|
567
|
+
messageId: "missingPromptImport",
|
|
568
|
+
node
|
|
569
|
+
});
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (prompt !== void 0 && !prompt.shared) {
|
|
573
|
+
if (!fileExists(join(prompt.dir, `${prompt.name}.ts`))) context.report({
|
|
574
|
+
data: { name: prompt.name },
|
|
575
|
+
messageId: "missingAgentSibling",
|
|
576
|
+
node
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
} };
|
|
580
|
+
},
|
|
581
|
+
meta: {
|
|
582
|
+
docs: RULE_DOCS["p3-agent-prompt-sibling"],
|
|
583
|
+
messages: {
|
|
584
|
+
missingAgentSibling: "Prompt file \"{{name}}.prompt.ts\" has no sibling agent file \"{{name}}.ts\" in the same directory (P3).",
|
|
585
|
+
missingPromptImport: "Agent file must import its prompt from \"{{expected}}\" (P3)."
|
|
586
|
+
},
|
|
587
|
+
type: "problem"
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
/**
|
|
593
|
+
* The full catalogue at its intended severities — spread into an oxlint
|
|
594
|
+
* `rules` map to enable everything in one line:
|
|
595
|
+
*
|
|
596
|
+
* rules: { ...recommendedRules }
|
|
597
|
+
*
|
|
598
|
+
* Hard conventions are errors; `m2w-*` (the model-id heuristic) is a warning.
|
|
599
|
+
*/
|
|
600
|
+
const recommendedRules = Object.fromEntries(Object.keys(plugin.rules).map((rule) => [`intelligence/${rule}`, /^\w+w-/.test(rule) ? "warn" : "error"]));
|
|
601
|
+
/**
|
|
602
|
+
* The composable fragment — wire the plugin and enable the whole catalogue.
|
|
603
|
+
* Designed to be composed with a base preset (e.g. `@jterrazz/typescript/oxlint`):
|
|
604
|
+
*
|
|
605
|
+
* import { compose, node } from '@jterrazz/typescript/oxlint';
|
|
606
|
+
* import { intelligence } from '@jterrazz/intelligence/oxlint';
|
|
607
|
+
* export default compose(node, intelligence);
|
|
608
|
+
*
|
|
609
|
+
* `jsPlugins` registers the tool-facing entry, `rules` is {@link recommendedRules}.
|
|
610
|
+
* `overrides` ships empty (no per-glob relaxation is needed today — every rule
|
|
611
|
+
* gates itself by file path/name internally) but is kept on the fragment's
|
|
612
|
+
* shape for parity with `@jterrazz/test`'s `testing` fragment and so a future
|
|
613
|
+
* relaxation has somewhere to go without a breaking shape change.
|
|
614
|
+
*/
|
|
615
|
+
const intelligence = {
|
|
616
|
+
jsPlugins: ["@jterrazz/intelligence/oxlint"],
|
|
617
|
+
overrides: [],
|
|
618
|
+
rules: recommendedRules
|
|
619
|
+
};
|
|
620
|
+
//#endregion
|
|
621
|
+
export { plugin as default, intelligence, recommendedRules };
|
|
622
|
+
|
|
623
|
+
//# sourceMappingURL=oxlint.js.map
|