@markbrutx/promptbook-core 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/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/annotations.d.ts +56 -0
- package/dist/annotations.d.ts.map +1 -0
- package/dist/annotations.js +50 -0
- package/dist/annotations.js.map +1 -0
- package/dist/bundle.d.ts +44 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.js +135 -0
- package/dist/bundle.js.map +1 -0
- package/dist/edge/index.js +192 -0
- package/dist/edge.d.ts +12 -0
- package/dist/edge.d.ts.map +1 -0
- package/dist/edge.js +11 -0
- package/dist/edge.js.map +1 -0
- package/dist/eval/assertions.d.ts +15 -0
- package/dist/eval/assertions.d.ts.map +1 -0
- package/dist/eval/assertions.js +131 -0
- package/dist/eval/assertions.js.map +1 -0
- package/dist/eval/evaluate.d.ts +15 -0
- package/dist/eval/evaluate.d.ts.map +1 -0
- package/dist/eval/evaluate.js +65 -0
- package/dist/eval/evaluate.js.map +1 -0
- package/dist/eval/load-fixtures.d.ts +12 -0
- package/dist/eval/load-fixtures.d.ts.map +1 -0
- package/dist/eval/load-fixtures.js +87 -0
- package/dist/eval/load-fixtures.js.map +1 -0
- package/dist/eval/types.d.ts +123 -0
- package/dist/eval/types.d.ts.map +1 -0
- package/dist/eval/types.js +2 -0
- package/dist/eval/types.js.map +1 -0
- package/dist/frontmatter.d.ts +12 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +22 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/fs.d.ts +11 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +20 -0
- package/dist/fs.js.map +1 -0
- package/dist/guards.d.ts +6 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +9 -0
- package/dist/guards.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/interpolate.d.ts +11 -0
- package/dist/interpolate.d.ts.map +1 -0
- package/dist/interpolate.js +25 -0
- package/dist/interpolate.js.map +1 -0
- package/dist/lint/lint.d.ts +11 -0
- package/dist/lint/lint.d.ts.map +1 -0
- package/dist/lint/lint.js +30 -0
- package/dist/lint/lint.js.map +1 -0
- package/dist/lint/references.d.ts +18 -0
- package/dist/lint/references.d.ts.map +1 -0
- package/dist/lint/references.js +39 -0
- package/dist/lint/references.js.map +1 -0
- package/dist/lint/rules/banned-tokens.d.ts +13 -0
- package/dist/lint/rules/banned-tokens.d.ts.map +1 -0
- package/dist/lint/rules/banned-tokens.js +38 -0
- package/dist/lint/rules/banned-tokens.js.map +1 -0
- package/dist/lint/rules/dangling-reference.d.ts +11 -0
- package/dist/lint/rules/dangling-reference.d.ts.map +1 -0
- package/dist/lint/rules/dangling-reference.js +37 -0
- package/dist/lint/rules/dangling-reference.js.map +1 -0
- package/dist/lint/rules/dead-rule.d.ts +21 -0
- package/dist/lint/rules/dead-rule.d.ts.map +1 -0
- package/dist/lint/rules/dead-rule.js +135 -0
- package/dist/lint/rules/dead-rule.js.map +1 -0
- package/dist/lint/rules/example-balance.d.ts +19 -0
- package/dist/lint/rules/example-balance.d.ts.map +1 -0
- package/dist/lint/rules/example-balance.js +57 -0
- package/dist/lint/rules/example-balance.js.map +1 -0
- package/dist/lint/rules/index.d.ts +28 -0
- package/dist/lint/rules/index.d.ts.map +1 -0
- package/dist/lint/rules/index.js +30 -0
- package/dist/lint/rules/index.js.map +1 -0
- package/dist/lint/rules/language-directive-position.d.ts +16 -0
- package/dist/lint/rules/language-directive-position.d.ts.map +1 -0
- package/dist/lint/rules/language-directive-position.js +42 -0
- package/dist/lint/rules/language-directive-position.js.map +1 -0
- package/dist/lint/rules/token-budget.d.ts +18 -0
- package/dist/lint/rules/token-budget.d.ts.map +1 -0
- package/dist/lint/rules/token-budget.js +39 -0
- package/dist/lint/rules/token-budget.js.map +1 -0
- package/dist/lint/rules/unused-fragment.d.ts +11 -0
- package/dist/lint/rules/unused-fragment.d.ts.map +1 -0
- package/dist/lint/rules/unused-fragment.js +33 -0
- package/dist/lint/rules/unused-fragment.js.map +1 -0
- package/dist/lint/types.d.ts +50 -0
- package/dist/lint/types.d.ts.map +1 -0
- package/dist/lint/types.js +2 -0
- package/dist/lint/types.js.map +1 -0
- package/dist/load.d.ts +12 -0
- package/dist/load.d.ts.map +1 -0
- package/dist/load.js +238 -0
- package/dist/load.js.map +1 -0
- package/dist/paths.d.ts +12 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +25 -0
- package/dist/paths.js.map +1 -0
- package/dist/resolve-book.d.ts +15 -0
- package/dist/resolve-book.d.ts.map +1 -0
- package/dist/resolve-book.js +195 -0
- package/dist/resolve-book.js.map +1 -0
- package/dist/resolve.d.ts +13 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +17 -0
- package/dist/resolve.js.map +1 -0
- package/dist/types.d.ts +173 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/package.json +48 -0
- package/src/annotations.ts +100 -0
- package/src/bundle.ts +163 -0
- package/src/edge.ts +11 -0
- package/src/eval/assertions.ts +174 -0
- package/src/eval/evaluate.ts +84 -0
- package/src/eval/load-fixtures.ts +91 -0
- package/src/eval/types.ts +134 -0
- package/src/frontmatter.ts +28 -0
- package/src/fs.ts +21 -0
- package/src/guards.ts +11 -0
- package/src/index.ts +84 -0
- package/src/interpolate.ts +27 -0
- package/src/lint/lint.ts +32 -0
- package/src/lint/references.ts +50 -0
- package/src/lint/rules/banned-tokens.ts +46 -0
- package/src/lint/rules/dangling-reference.ts +43 -0
- package/src/lint/rules/dead-rule.ts +147 -0
- package/src/lint/rules/example-balance.ts +68 -0
- package/src/lint/rules/index.ts +47 -0
- package/src/lint/rules/language-directive-position.ts +51 -0
- package/src/lint/rules/token-budget.ts +50 -0
- package/src/lint/rules/unused-fragment.ts +38 -0
- package/src/lint/types.ts +55 -0
- package/src/load.ts +282 -0
- package/src/paths.ts +27 -0
- package/src/resolve-book.ts +237 -0
- package/src/resolve.ts +18 -0
- package/src/types.ts +191 -0
package/src/fs.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FsAdapter } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default filesystem adapter backed by `node:fs/promises`.
|
|
5
|
+
*
|
|
6
|
+
* The import is lazy so that simply importing the core does not pull in any
|
|
7
|
+
* Node-only module: callers in Deno/Bun (or tests) can inject their own
|
|
8
|
+
* {@link FsAdapter} and never touch this path. The module is resolved once
|
|
9
|
+
* per adapter (when `nodeFs()` is called), not on every read.
|
|
10
|
+
*/
|
|
11
|
+
export function nodeFs(): FsAdapter {
|
|
12
|
+
const fsModule = import("node:fs/promises");
|
|
13
|
+
return {
|
|
14
|
+
async readFile(path: string): Promise<string> {
|
|
15
|
+
return (await fsModule).readFile(path, "utf8");
|
|
16
|
+
},
|
|
17
|
+
async readDir(path: string): Promise<string[]> {
|
|
18
|
+
return (await fsModule).readdir(path);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
package/src/guards.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ContextValue } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** True for a plain object (a YAML/JSON mapping); excludes arrays and null. */
|
|
4
|
+
export function isMapping(value: unknown): value is Record<string, unknown> {
|
|
5
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** True for a scalar that is a valid {@link ContextValue} (string/number/boolean). */
|
|
9
|
+
export function isContextValue(value: unknown): value is ContextValue {
|
|
10
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
11
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
Annotation,
|
|
3
|
+
AnnotationAnchor,
|
|
4
|
+
AnnotationStatus,
|
|
5
|
+
AnnotationTarget,
|
|
6
|
+
} from "./annotations.js";
|
|
7
|
+
export {
|
|
8
|
+
ANNOTATION_QUEUE_DIR,
|
|
9
|
+
ANNOTATION_QUEUE_FILE,
|
|
10
|
+
parseInbox,
|
|
11
|
+
serializeAnnotationLine,
|
|
12
|
+
serializeInbox,
|
|
13
|
+
} from "./annotations.js";
|
|
14
|
+
export type { SerializeBookOptions } from "./bundle.js";
|
|
15
|
+
export { serializeBook, serializeBookExpression, serializeBookJson } from "./bundle.js";
|
|
16
|
+
export { defaultAssertions } from "./eval/assertions.js";
|
|
17
|
+
export { evaluate } from "./eval/evaluate.js";
|
|
18
|
+
export { loadFixtures } from "./eval/load-fixtures.js";
|
|
19
|
+
export type {
|
|
20
|
+
Assertion,
|
|
21
|
+
AssertionFn,
|
|
22
|
+
AssertionRegistry,
|
|
23
|
+
AssertionResult,
|
|
24
|
+
EvalInput,
|
|
25
|
+
EvalReport,
|
|
26
|
+
Fixture,
|
|
27
|
+
FixtureResult,
|
|
28
|
+
ModelAdapter,
|
|
29
|
+
ModelRequest,
|
|
30
|
+
ModelResponse,
|
|
31
|
+
ModelUsage,
|
|
32
|
+
} from "./eval/types.js";
|
|
33
|
+
export { parseFrontmatter } from "./frontmatter.js";
|
|
34
|
+
export { nodeFs } from "./fs.js";
|
|
35
|
+
export { interpolate } from "./interpolate.js";
|
|
36
|
+
export { lint } from "./lint/lint.js";
|
|
37
|
+
export type { FragmentReference } from "./lint/references.js";
|
|
38
|
+
export { iterateReferences } from "./lint/references.js";
|
|
39
|
+
export type {
|
|
40
|
+
BannedTokensOptions,
|
|
41
|
+
DanglingReferenceOptions,
|
|
42
|
+
DeadRuleOptions,
|
|
43
|
+
ExampleBalanceOptions,
|
|
44
|
+
LanguageDirectivePositionOptions,
|
|
45
|
+
TokenBudgetOptions,
|
|
46
|
+
UnusedFragmentOptions,
|
|
47
|
+
} from "./lint/rules/index.js";
|
|
48
|
+
export {
|
|
49
|
+
bannedTokens,
|
|
50
|
+
type DefaultRulesOptions,
|
|
51
|
+
danglingReference,
|
|
52
|
+
deadRule,
|
|
53
|
+
defaultRules,
|
|
54
|
+
estimateTokensByChars,
|
|
55
|
+
exampleBalance,
|
|
56
|
+
languageDirectivePosition,
|
|
57
|
+
tokenBudget,
|
|
58
|
+
unusedFragment,
|
|
59
|
+
} from "./lint/rules/index.js";
|
|
60
|
+
export type { LintFinding, LintInput, LintReport, LintRule, LintScope, Severity } from "./lint/types.js";
|
|
61
|
+
export { loadPrompts } from "./load.js";
|
|
62
|
+
export { resolve } from "./resolve.js";
|
|
63
|
+
export { resolveBook } from "./resolve-book.js";
|
|
64
|
+
export type {
|
|
65
|
+
AddTrace,
|
|
66
|
+
CodePrompt,
|
|
67
|
+
CodePromptSample,
|
|
68
|
+
Composition,
|
|
69
|
+
Context,
|
|
70
|
+
ContextValue,
|
|
71
|
+
ForbidTrace,
|
|
72
|
+
Fragment,
|
|
73
|
+
FsAdapter,
|
|
74
|
+
PromptBook,
|
|
75
|
+
ReplaceTrace,
|
|
76
|
+
ResolveInput,
|
|
77
|
+
ResolveResult,
|
|
78
|
+
Rule,
|
|
79
|
+
RuleAction,
|
|
80
|
+
RuleTrace,
|
|
81
|
+
Trace,
|
|
82
|
+
UnmatchedAxis,
|
|
83
|
+
When,
|
|
84
|
+
} from "./types.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Context } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Matches `${path}` placeholders, capturing an optional leading `\` escape. */
|
|
4
|
+
const VAR_RE = /(\\?)\$\{([^}]+)\}/g;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Substitute `${path}` placeholders in `body` using `context`.
|
|
8
|
+
*
|
|
9
|
+
* - A missing key resolves to an empty string and triggers `onMissing(key)`.
|
|
10
|
+
* Interpolation never throws.
|
|
11
|
+
* - `\${path}` is an escape and renders the literal `${path}`.
|
|
12
|
+
* - Lookup is by flat key, matching the flat {@link Context} shape.
|
|
13
|
+
*/
|
|
14
|
+
export function interpolate(body: string, context: Context, onMissing: (key: string) => void): string {
|
|
15
|
+
return body.replace(VAR_RE, (_match, backslash: string, expr: string) => {
|
|
16
|
+
if (backslash) {
|
|
17
|
+
return `\${${expr}}`;
|
|
18
|
+
}
|
|
19
|
+
const key = expr.trim();
|
|
20
|
+
const value = context[key];
|
|
21
|
+
if (value === undefined) {
|
|
22
|
+
onMissing(key);
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
return String(value);
|
|
26
|
+
});
|
|
27
|
+
}
|
package/src/lint/lint.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defaultRules } from "./rules/index.js";
|
|
2
|
+
import type { LintFinding, LintInput, LintReport, LintRule } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Run lint rules over an input and aggregate the findings.
|
|
6
|
+
*
|
|
7
|
+
* Book-scope rules always run. Resolved-scope rules run only when
|
|
8
|
+
* `input.result` is present, so calling `lint({ book })` lints structure only.
|
|
9
|
+
* The engine has no intelligence of its own: it dispatches by scope and counts
|
|
10
|
+
* severities. All judgement lives in the rules.
|
|
11
|
+
*/
|
|
12
|
+
export function lint(input: LintInput, rules: LintRule[] = defaultRules()): LintReport {
|
|
13
|
+
const findings: LintFinding[] = [];
|
|
14
|
+
for (const rule of rules) {
|
|
15
|
+
if (rule.scope === "resolved" && input.result === undefined) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
findings.push(...rule.check(input));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let errorCount = 0;
|
|
22
|
+
let warningCount = 0;
|
|
23
|
+
for (const finding of findings) {
|
|
24
|
+
if (finding.severity === "error") {
|
|
25
|
+
errorCount += 1;
|
|
26
|
+
} else if (finding.severity === "warning") {
|
|
27
|
+
warningCount += 1;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { findings, errorCount, warningCount };
|
|
32
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { PromptBook } from "../types.js";
|
|
2
|
+
|
|
3
|
+
/** A single mention of a fragment id, with enough context to report it. */
|
|
4
|
+
export interface FragmentReference {
|
|
5
|
+
id: string;
|
|
6
|
+
composition: string;
|
|
7
|
+
/** Where in a composition the fragment id is mentioned. */
|
|
8
|
+
role: "base" | "order" | "add" | "after" | "replace-from" | "replace-to" | "forbid" | "rule-order";
|
|
9
|
+
/** Index of the rule the reference came from, when it came from a rule. */
|
|
10
|
+
ruleIndex?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Yield every place a fragment id is referenced across all compositions: base
|
|
15
|
+
* and explicit order lists, and each rule's add/after/replace/forbid/order.
|
|
16
|
+
* Shared by the `unused-fragment` and `dangling-reference` rules so both see
|
|
17
|
+
* the same reference surface.
|
|
18
|
+
*/
|
|
19
|
+
export function* iterateReferences(book: PromptBook): Generator<FragmentReference> {
|
|
20
|
+
for (const composition of book.compositions.values()) {
|
|
21
|
+
const composed = composition.name;
|
|
22
|
+
for (const id of composition.base) {
|
|
23
|
+
yield { id, composition: composed, role: "base" };
|
|
24
|
+
}
|
|
25
|
+
if (composition.order) {
|
|
26
|
+
for (const id of composition.order) {
|
|
27
|
+
yield { id, composition: composed, role: "order" };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const rule of composition.rules) {
|
|
31
|
+
const ruleIndex = rule.index;
|
|
32
|
+
for (const id of rule.add ?? []) {
|
|
33
|
+
yield { id, composition: composed, role: "add", ruleIndex };
|
|
34
|
+
}
|
|
35
|
+
if (rule.after !== undefined) {
|
|
36
|
+
yield { id: rule.after, composition: composed, role: "after", ruleIndex };
|
|
37
|
+
}
|
|
38
|
+
for (const [from, to] of Object.entries(rule.replace ?? {})) {
|
|
39
|
+
yield { id: from, composition: composed, role: "replace-from", ruleIndex };
|
|
40
|
+
yield { id: to, composition: composed, role: "replace-to", ruleIndex };
|
|
41
|
+
}
|
|
42
|
+
for (const id of rule.forbid ?? []) {
|
|
43
|
+
yield { id, composition: composed, role: "forbid", ruleIndex };
|
|
44
|
+
}
|
|
45
|
+
for (const id of rule.order ?? []) {
|
|
46
|
+
yield { id, composition: composed, role: "rule-order", ruleIndex };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { LintFinding, LintRule, Severity } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export interface BannedTokensOptions {
|
|
4
|
+
/** Substrings or patterns that must not appear in the assembled text. */
|
|
5
|
+
tokens?: (string | RegExp)[];
|
|
6
|
+
severity?: Severity;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Em-dash: a common stylistic ban, and deliberately generic (not domain-specific).
|
|
10
|
+
const EM_DASH = "\u2014";
|
|
11
|
+
const DEFAULT_BANNED: (string | RegExp)[] = [EM_DASH];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `banned-tokens` (resolved): the assembled text must not contain any of the
|
|
15
|
+
* configured substrings or patterns. The default bans the em-dash. Patterns
|
|
16
|
+
* should not use the global flag, since each token is tested once.
|
|
17
|
+
*/
|
|
18
|
+
export function bannedTokens(options: BannedTokensOptions = {}): LintRule {
|
|
19
|
+
const tokens = options.tokens ?? DEFAULT_BANNED;
|
|
20
|
+
const severity = options.severity ?? "error";
|
|
21
|
+
return {
|
|
22
|
+
id: "banned-tokens",
|
|
23
|
+
description: "Resolved prompt must not contain banned substrings or patterns.",
|
|
24
|
+
scope: "resolved",
|
|
25
|
+
check(input) {
|
|
26
|
+
const result = input.result;
|
|
27
|
+
if (result === undefined) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
const text = result.text;
|
|
31
|
+
const findings: LintFinding[] = [];
|
|
32
|
+
for (const token of tokens) {
|
|
33
|
+
const matched = typeof token === "string" ? text.includes(token) : token.test(text);
|
|
34
|
+
if (matched) {
|
|
35
|
+
const label = typeof token === "string" ? JSON.stringify(token) : String(token);
|
|
36
|
+
findings.push({
|
|
37
|
+
ruleId: "banned-tokens",
|
|
38
|
+
severity,
|
|
39
|
+
message: `text contains banned token ${label}`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return findings;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { iterateReferences } from "../references.js";
|
|
2
|
+
import type { LintFinding, LintRule, Severity } from "../types.js";
|
|
3
|
+
|
|
4
|
+
export interface DanglingReferenceOptions {
|
|
5
|
+
severity?: Severity;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `dangling-reference` (book): any reference (base, order, or a rule's
|
|
10
|
+
* add/replace/forbid/order/after) that points at a fragment id which does not
|
|
11
|
+
* exist is a hard error — the prompt cannot assemble as written.
|
|
12
|
+
*/
|
|
13
|
+
export function danglingReference(options: DanglingReferenceOptions = {}): LintRule {
|
|
14
|
+
const severity = options.severity ?? "error";
|
|
15
|
+
return {
|
|
16
|
+
id: "dangling-reference",
|
|
17
|
+
description: "Every referenced fragment id must exist.",
|
|
18
|
+
scope: "book",
|
|
19
|
+
check(input) {
|
|
20
|
+
const findings: LintFinding[] = [];
|
|
21
|
+
for (const reference of iterateReferences(input.book)) {
|
|
22
|
+
if (input.book.fragments.has(reference.id)) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const where =
|
|
26
|
+
reference.ruleIndex !== undefined
|
|
27
|
+
? `rule #${reference.ruleIndex} (${reference.role})`
|
|
28
|
+
: reference.role;
|
|
29
|
+
const finding: LintFinding = {
|
|
30
|
+
ruleId: "dangling-reference",
|
|
31
|
+
severity,
|
|
32
|
+
message: `composition "${reference.composition}" references unknown fragment "${reference.id}" via ${where}`,
|
|
33
|
+
fragmentId: reference.id,
|
|
34
|
+
};
|
|
35
|
+
if (reference.ruleIndex !== undefined) {
|
|
36
|
+
finding.ruleIndex = reference.ruleIndex;
|
|
37
|
+
}
|
|
38
|
+
findings.push(finding);
|
|
39
|
+
}
|
|
40
|
+
return findings;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { valuesEqual } from "../../resolve-book.js";
|
|
2
|
+
import type { Composition, When } from "../../types.js";
|
|
3
|
+
import type { LintFinding, LintRule, Severity } from "../types.js";
|
|
4
|
+
|
|
5
|
+
export interface DeadRuleOptions {
|
|
6
|
+
severity?: Severity;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `dead-rule` (book): catch rules that statically cannot do what they say.
|
|
11
|
+
* For each rule it flags a `replace` whose source is never present, an `add` of
|
|
12
|
+
* an already-present id, and an `order` naming an id that can never appear.
|
|
13
|
+
*
|
|
14
|
+
* `when` is taken into account: a prior rule only shapes the present-set a later
|
|
15
|
+
* rule sees if it is *guaranteed to co-fire* — i.e. its `when` is implied by the
|
|
16
|
+
* later rule's `when`. This keeps mutually-exclusive single-axis swaps (e.g.
|
|
17
|
+
* `tone=a|b|c` each replacing the same base fragment) from reading as dead,
|
|
18
|
+
* while still catching a replace whose source was unconditionally consumed by an
|
|
19
|
+
* earlier rule.
|
|
20
|
+
*
|
|
21
|
+
* v0 limitation: still a static check. "A rule that never fires under any
|
|
22
|
+
* context" (which requires enumerating contexts) remains out of scope.
|
|
23
|
+
*/
|
|
24
|
+
export function deadRule(options: DeadRuleOptions = {}): LintRule {
|
|
25
|
+
const severity = options.severity ?? "warning";
|
|
26
|
+
return {
|
|
27
|
+
id: "dead-rule",
|
|
28
|
+
description: "Rules should affect fragments that can be present (static check).",
|
|
29
|
+
scope: "book",
|
|
30
|
+
check(input) {
|
|
31
|
+
const findings: LintFinding[] = [];
|
|
32
|
+
for (const composition of input.book.compositions.values()) {
|
|
33
|
+
const everPresent = everPresentSet(composition);
|
|
34
|
+
composition.rules.forEach((rule, position) => {
|
|
35
|
+
const present = presentSetFor(composition.base, composition.rules, position, rule.when);
|
|
36
|
+
switch (rule.action) {
|
|
37
|
+
case "add":
|
|
38
|
+
for (const id of rule.add ?? []) {
|
|
39
|
+
if (present.has(id)) {
|
|
40
|
+
findings.push({
|
|
41
|
+
ruleId: "dead-rule",
|
|
42
|
+
severity,
|
|
43
|
+
message: `rule #${rule.index} in "${composition.name}" adds "${id}", which is already present`,
|
|
44
|
+
fragmentId: id,
|
|
45
|
+
ruleIndex: rule.index,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
break;
|
|
50
|
+
case "replace":
|
|
51
|
+
for (const from of Object.keys(rule.replace ?? {})) {
|
|
52
|
+
if (!present.has(from)) {
|
|
53
|
+
findings.push({
|
|
54
|
+
ruleId: "dead-rule",
|
|
55
|
+
severity,
|
|
56
|
+
message: `rule #${rule.index} in "${composition.name}" replaces "${from}", which is never present`,
|
|
57
|
+
fragmentId: from,
|
|
58
|
+
ruleIndex: rule.index,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
break;
|
|
63
|
+
case "order":
|
|
64
|
+
for (const id of rule.order ?? []) {
|
|
65
|
+
if (!everPresent.has(id)) {
|
|
66
|
+
findings.push({
|
|
67
|
+
ruleId: "dead-rule",
|
|
68
|
+
severity,
|
|
69
|
+
message: `rule #${rule.index} in "${composition.name}" orders unknown id "${id}"`,
|
|
70
|
+
fragmentId: id,
|
|
71
|
+
ruleIndex: rule.index,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
case "forbid":
|
|
77
|
+
// `forbid` does not feed the static present-set in v0.
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return findings;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The present-set a rule sees: `base` mutated by every earlier rule that is
|
|
89
|
+
* guaranteed to co-fire (its `when` is implied by this rule's `when`).
|
|
90
|
+
*/
|
|
91
|
+
function presentSetFor(
|
|
92
|
+
base: readonly string[],
|
|
93
|
+
rules: Composition["rules"],
|
|
94
|
+
position: number,
|
|
95
|
+
currentWhen: When,
|
|
96
|
+
): Set<string> {
|
|
97
|
+
const present = new Set<string>(base);
|
|
98
|
+
for (let i = 0; i < position; i++) {
|
|
99
|
+
const prior = rules[i];
|
|
100
|
+
if (!prior || !firesWhenever(prior.when, currentWhen)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (prior.action === "add") {
|
|
104
|
+
for (const id of prior.add ?? []) {
|
|
105
|
+
present.add(id);
|
|
106
|
+
}
|
|
107
|
+
} else if (prior.action === "replace") {
|
|
108
|
+
for (const [from, to] of Object.entries(prior.replace ?? {})) {
|
|
109
|
+
if (present.has(from)) {
|
|
110
|
+
present.delete(from);
|
|
111
|
+
present.add(to);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return present;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Every id that can ever appear: base plus all add ids and all replace targets. */
|
|
120
|
+
function everPresentSet(composition: Composition): Set<string> {
|
|
121
|
+
const present = new Set<string>(composition.base);
|
|
122
|
+
for (const rule of composition.rules) {
|
|
123
|
+
if (rule.action === "add") {
|
|
124
|
+
for (const id of rule.add ?? []) {
|
|
125
|
+
present.add(id);
|
|
126
|
+
}
|
|
127
|
+
} else if (rule.action === "replace") {
|
|
128
|
+
for (const to of Object.values(rule.replace ?? {})) {
|
|
129
|
+
present.add(to);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return present;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** True if `prior` fires in every context where `current` fires — i.e. every
|
|
137
|
+
* constraint in `prior` is also required (same value) by `current`. An empty
|
|
138
|
+
* `prior` (unconditional) always co-fires. */
|
|
139
|
+
function firesWhenever(prior: When, current: When): boolean {
|
|
140
|
+
for (const [key, value] of Object.entries(prior)) {
|
|
141
|
+
const actual = current[key];
|
|
142
|
+
if (actual === undefined || !valuesEqual(actual, value)) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { LintRule, Severity } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export interface ExampleBalanceOptions {
|
|
4
|
+
/** Fragment `kind` treated as an example. */
|
|
5
|
+
kind?: string;
|
|
6
|
+
/** Tag prefix that groups examples, e.g. "case:". */
|
|
7
|
+
groupTagPrefix?: string;
|
|
8
|
+
/** Maximum allowed difference between the largest and smallest group. */
|
|
9
|
+
maxSkew?: number;
|
|
10
|
+
severity?: Severity;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `example-balance` (resolved): few-shot examples (fragments of the configured
|
|
15
|
+
* kind) are grouped by their `groupTagPrefix` tag (e.g. `case:positive`). When
|
|
16
|
+
* the largest group exceeds the smallest by more than `maxSkew`, attention is
|
|
17
|
+
* skewed and a finding is raised. Examples without a group tag are ignored, so
|
|
18
|
+
* the rule only compares groups that actually have examples.
|
|
19
|
+
*/
|
|
20
|
+
export function exampleBalance(options: ExampleBalanceOptions = {}): LintRule {
|
|
21
|
+
const kind = options.kind ?? "example";
|
|
22
|
+
const groupTagPrefix = options.groupTagPrefix ?? "case:";
|
|
23
|
+
const maxSkew = options.maxSkew ?? 1;
|
|
24
|
+
const severity = options.severity ?? "warning";
|
|
25
|
+
return {
|
|
26
|
+
id: "example-balance",
|
|
27
|
+
description: `Example groups (kind "${kind}", tag "${groupTagPrefix}*") should differ by at most ${maxSkew}.`,
|
|
28
|
+
scope: "resolved",
|
|
29
|
+
check(input) {
|
|
30
|
+
const result = input.result;
|
|
31
|
+
if (result === undefined) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
const counts = new Map<string, number>();
|
|
35
|
+
for (const id of result.trace.finalOrder) {
|
|
36
|
+
const fragment = input.book.fragments.get(id);
|
|
37
|
+
if (fragment?.kind !== kind) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
for (const tag of fragment.tags ?? []) {
|
|
41
|
+
if (tag.startsWith(groupTagPrefix)) {
|
|
42
|
+
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (counts.size < 2) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const values = [...counts.values()];
|
|
50
|
+
const max = Math.max(...values);
|
|
51
|
+
const min = Math.min(...values);
|
|
52
|
+
if (max - min <= maxSkew) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const summary = [...counts.entries()]
|
|
56
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
57
|
+
.map(([group, count]) => `${group}=${count}`)
|
|
58
|
+
.join(", ");
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
ruleId: "example-balance",
|
|
62
|
+
severity,
|
|
63
|
+
message: `example groups are imbalanced (${summary}); skew ${max - min} exceeds ${maxSkew}`,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { LintRule } from "../types.js";
|
|
2
|
+
import { bannedTokens } from "./banned-tokens.js";
|
|
3
|
+
import { danglingReference } from "./dangling-reference.js";
|
|
4
|
+
import { deadRule } from "./dead-rule.js";
|
|
5
|
+
import { exampleBalance } from "./example-balance.js";
|
|
6
|
+
import { languageDirectivePosition } from "./language-directive-position.js";
|
|
7
|
+
import { tokenBudget } from "./token-budget.js";
|
|
8
|
+
import { unusedFragment } from "./unused-fragment.js";
|
|
9
|
+
|
|
10
|
+
/** Options surfaced through {@link defaultRules}; per-rule factories take more. */
|
|
11
|
+
export interface DefaultRulesOptions {
|
|
12
|
+
/** Override the token-budget ceiling. */
|
|
13
|
+
maxTokens?: number;
|
|
14
|
+
/** Override the banned-tokens list. */
|
|
15
|
+
bannedTokens?: (string | RegExp)[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The built-in rule set, instantiated with optional overrides. Callers can
|
|
20
|
+
* also build their own array and pass it to `lint` to swap the set entirely.
|
|
21
|
+
*/
|
|
22
|
+
export function defaultRules(options: DefaultRulesOptions = {}): LintRule[] {
|
|
23
|
+
return [
|
|
24
|
+
tokenBudget(options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}),
|
|
25
|
+
languageDirectivePosition(),
|
|
26
|
+
exampleBalance(),
|
|
27
|
+
bannedTokens(options.bannedTokens !== undefined ? { tokens: options.bannedTokens } : {}),
|
|
28
|
+
unusedFragment(),
|
|
29
|
+
danglingReference(),
|
|
30
|
+
deadRule(),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type { BannedTokensOptions } from "./banned-tokens.js";
|
|
35
|
+
export { bannedTokens } from "./banned-tokens.js";
|
|
36
|
+
export type { DanglingReferenceOptions } from "./dangling-reference.js";
|
|
37
|
+
export { danglingReference } from "./dangling-reference.js";
|
|
38
|
+
export type { DeadRuleOptions } from "./dead-rule.js";
|
|
39
|
+
export { deadRule } from "./dead-rule.js";
|
|
40
|
+
export type { ExampleBalanceOptions } from "./example-balance.js";
|
|
41
|
+
export { exampleBalance } from "./example-balance.js";
|
|
42
|
+
export type { LanguageDirectivePositionOptions } from "./language-directive-position.js";
|
|
43
|
+
export { languageDirectivePosition } from "./language-directive-position.js";
|
|
44
|
+
export type { TokenBudgetOptions } from "./token-budget.js";
|
|
45
|
+
export { estimateTokensByChars, tokenBudget } from "./token-budget.js";
|
|
46
|
+
export type { UnusedFragmentOptions } from "./unused-fragment.js";
|
|
47
|
+
export { unusedFragment } from "./unused-fragment.js";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { LintFinding, LintRule, Severity } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export interface LanguageDirectivePositionOptions {
|
|
4
|
+
/** Fragment `kind` that must sit at an edge. */
|
|
5
|
+
kind?: string;
|
|
6
|
+
/** How many leading/trailing positions count as an edge. */
|
|
7
|
+
edgeWindow?: number;
|
|
8
|
+
severity?: Severity;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `language-directive-position` (resolved): fragments of the configured kind
|
|
13
|
+
* must appear within the first or last `edgeWindow` positions of the final
|
|
14
|
+
* order. A language directive buried in the middle is easy for a model to
|
|
15
|
+
* miss, so it is flagged.
|
|
16
|
+
*/
|
|
17
|
+
export function languageDirectivePosition(options: LanguageDirectivePositionOptions = {}): LintRule {
|
|
18
|
+
const kind = options.kind ?? "language-directive";
|
|
19
|
+
const edgeWindow = options.edgeWindow ?? 2;
|
|
20
|
+
const severity = options.severity ?? "warning";
|
|
21
|
+
return {
|
|
22
|
+
id: "language-directive-position",
|
|
23
|
+
description: `Fragments of kind "${kind}" should sit within the first/last ${edgeWindow} positions.`,
|
|
24
|
+
scope: "resolved",
|
|
25
|
+
check(input) {
|
|
26
|
+
const result = input.result;
|
|
27
|
+
if (result === undefined) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
const order = result.trace.finalOrder;
|
|
31
|
+
const total = order.length;
|
|
32
|
+
const findings: LintFinding[] = [];
|
|
33
|
+
order.forEach((id, position) => {
|
|
34
|
+
if (input.book.fragments.get(id)?.kind !== kind) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const atStart = position < edgeWindow;
|
|
38
|
+
const atEnd = position >= total - edgeWindow;
|
|
39
|
+
if (!atStart && !atEnd) {
|
|
40
|
+
findings.push({
|
|
41
|
+
ruleId: "language-directive-position",
|
|
42
|
+
severity,
|
|
43
|
+
message: `fragment "${id}" (kind "${kind}") is at position ${position + 1} of ${total}, not within the first/last ${edgeWindow}`,
|
|
44
|
+
fragmentId: id,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
return findings;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { LintRule, Severity } from "../types.js";
|
|
2
|
+
|
|
3
|
+
/** Heuristic token estimate: ~4 characters per token. Not a real tokenizer. */
|
|
4
|
+
export function estimateTokensByChars(text: string): number {
|
|
5
|
+
return Math.ceil(text.length / 4);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TokenBudgetOptions {
|
|
9
|
+
/** Maximum estimated tokens before a finding is raised. */
|
|
10
|
+
maxTokens?: number;
|
|
11
|
+
/** Inject a real token counter; defaults to the chars/4 heuristic. */
|
|
12
|
+
estimateTokens?: (text: string) => number;
|
|
13
|
+
severity?: Severity;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_MAX_TOKENS = 8000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `token-budget` (resolved): estimate the assembled text's token count and
|
|
20
|
+
* report when it exceeds `maxTokens`. The estimate is a deterministic
|
|
21
|
+
* heuristic (chars/4) so the core stays dependency-free; callers can inject a
|
|
22
|
+
* precise counter via `estimateTokens`.
|
|
23
|
+
*/
|
|
24
|
+
export function tokenBudget(options: TokenBudgetOptions = {}): LintRule {
|
|
25
|
+
const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
26
|
+
const estimate = options.estimateTokens ?? estimateTokensByChars;
|
|
27
|
+
const severity = options.severity ?? "warning";
|
|
28
|
+
return {
|
|
29
|
+
id: "token-budget",
|
|
30
|
+
description: `Resolved prompt should fit within ~${maxTokens} estimated tokens.`,
|
|
31
|
+
scope: "resolved",
|
|
32
|
+
check(input) {
|
|
33
|
+
const result = input.result;
|
|
34
|
+
if (result === undefined) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const tokens = estimate(result.text);
|
|
38
|
+
if (tokens <= maxTokens) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
ruleId: "token-budget",
|
|
44
|
+
severity,
|
|
45
|
+
message: `estimated ${tokens} tokens exceeds budget of ${maxTokens}`,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|