@prongsjs/core 0.0.0-stage → 0.2.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 +4 -2
- package/dist/engine/baseline.d.ts +17 -0
- package/dist/engine/baseline.js +29 -0
- package/dist/engine/check.d.ts +4 -0
- package/dist/engine/check.js +90 -0
- package/dist/engine/find.d.ts +20 -0
- package/dist/engine/find.js +81 -0
- package/dist/engine/index.d.ts +10 -0
- package/dist/engine/index.js +6 -0
- package/dist/engine/rule-docs.d.ts +3 -0
- package/dist/engine/rule-docs.js +31 -0
- package/dist/engine/spec-md.d.ts +24 -0
- package/dist/engine/spec-md.js +76 -0
- package/dist/engine/types.d.ts +109 -0
- package/dist/engine/types.js +1 -0
- package/dist/engine/util.d.ts +11 -0
- package/dist/engine/util.js +52 -0
- package/dist/export.d.ts +18 -0
- package/dist/export.js +17 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/registry.d.ts +32 -0
- package/dist/registry.js +70 -0
- package/dist/spec.d.ts +84 -0
- package/dist/spec.js +18 -0
- package/dist/validate.d.ts +18 -0
- package/dist/validate.js +115 -0
- package/package.json +50 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Drew Poling
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @prongsjs/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Prongs intent spec: typed intent registries, profiles, and the engine behind `prongs check` and `PRONGS.md`. It knows nothing about UI; `@prongsjs/ui`, `@prongsjs/errors` and `@prongsjs/logs` are profiles built on it.
|
|
4
|
+
|
|
5
|
+
You usually get it through `npx prongs add`. Early (0.x): expect changes. Full guide: https://github.com/drewpoling2/prongs#readme
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Violation } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* A baseline records existing violations so a project can adopt checks without
|
|
4
|
+
* fixing everything first. Only new violations fail; the count can only go down.
|
|
5
|
+
* Entries are keyed by rule, file, intent and source line text (not line numbers),
|
|
6
|
+
* so unrelated edits that shift lines do not invalidate the baseline.
|
|
7
|
+
*/
|
|
8
|
+
export type Baseline = {
|
|
9
|
+
version: 1;
|
|
10
|
+
entries: Record<string, number>;
|
|
11
|
+
};
|
|
12
|
+
export declare function fingerprint(v: Violation): string;
|
|
13
|
+
export declare function createBaseline(violations: readonly Violation[]): Baseline;
|
|
14
|
+
export declare function applyBaseline(violations: readonly Violation[], baseline: Baseline): {
|
|
15
|
+
fresh: Violation[];
|
|
16
|
+
baselined: Violation[];
|
|
17
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function fingerprint(v) {
|
|
2
|
+
return [v.rule, v.path, v.intent ?? '', v.snippet.replace(/\s+/g, ' ')].join('|');
|
|
3
|
+
}
|
|
4
|
+
export function createBaseline(violations) {
|
|
5
|
+
const entries = {};
|
|
6
|
+
for (const v of violations) {
|
|
7
|
+
if (v.severity === 'info')
|
|
8
|
+
continue;
|
|
9
|
+
const key = fingerprint(v);
|
|
10
|
+
entries[key] = (entries[key] ?? 0) + 1;
|
|
11
|
+
}
|
|
12
|
+
return { version: 1, entries: Object.fromEntries(Object.entries(entries).sort()) };
|
|
13
|
+
}
|
|
14
|
+
export function applyBaseline(violations, baseline) {
|
|
15
|
+
const remaining = { ...baseline.entries };
|
|
16
|
+
const fresh = [];
|
|
17
|
+
const baselined = [];
|
|
18
|
+
for (const v of violations) {
|
|
19
|
+
const key = fingerprint(v);
|
|
20
|
+
if (v.severity !== 'info' && (remaining[key] ?? 0) > 0) {
|
|
21
|
+
remaining[key]--;
|
|
22
|
+
baselined.push(v);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
fresh.push(v);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { fresh, baselined };
|
|
29
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AnyRegistry } from '../registry.js';
|
|
2
|
+
import type { ProfileTooling, SourceFile, Violation } from './types.js';
|
|
3
|
+
/** Runs every applicable profile's usages, per-intent checks and rules over the files. */
|
|
4
|
+
export declare function checkFiles(files: readonly SourceFile[], registry: AnyRegistry, toolings: readonly ProfileTooling[]): Violation[];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { intentsUsing, nearestIntents } from './util.js';
|
|
2
|
+
/** Checks collected along the intent's ref chain, own checks last. */
|
|
3
|
+
function checksFor(registry, id, seen = new Set()) {
|
|
4
|
+
if (seen.has(id) || !registry.has(id))
|
|
5
|
+
return [];
|
|
6
|
+
seen.add(id);
|
|
7
|
+
const intent = registry.defs[id];
|
|
8
|
+
return [
|
|
9
|
+
...(intent.refs ?? []).flatMap((ref) => checksFor(registry, ref, seen)),
|
|
10
|
+
...(intent.checks ?? []),
|
|
11
|
+
];
|
|
12
|
+
}
|
|
13
|
+
function usageViolation(usage, rule, message, suggestions) {
|
|
14
|
+
return {
|
|
15
|
+
rule,
|
|
16
|
+
severity: 'error',
|
|
17
|
+
path: usage.path,
|
|
18
|
+
line: usage.line,
|
|
19
|
+
column: usage.column,
|
|
20
|
+
snippet: usage.snippet,
|
|
21
|
+
intent: usage.intent,
|
|
22
|
+
message,
|
|
23
|
+
suggestions,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function checkUsage(ctx, tooling, usage) {
|
|
27
|
+
const { registry } = ctx;
|
|
28
|
+
if (!registry.has(usage.intent)) {
|
|
29
|
+
return [
|
|
30
|
+
usageViolation(usage, 'unknown-intent', `Unknown intent "${usage.intent}".`, nearestIntents(registry, usage.intent).map((id) => ({
|
|
31
|
+
intent: id,
|
|
32
|
+
text: registry.defs[id].description,
|
|
33
|
+
}))),
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
if (registry.isInternal(usage.intent)) {
|
|
37
|
+
return [
|
|
38
|
+
usageViolation(usage, 'internal-intent', `"${usage.intent}" describes a look and can only be used as a ref. Pick an intent by what this element does.`, intentsUsing(registry, usage.intent).map((id) => ({
|
|
39
|
+
intent: id,
|
|
40
|
+
text: registry.defs[id].description,
|
|
41
|
+
}))),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
const violations = [];
|
|
45
|
+
const intent = registry.defs[usage.intent];
|
|
46
|
+
if (intent.status === 'deprecated') {
|
|
47
|
+
const instead = intent.notFor?.find((e) => e.instead)?.instead;
|
|
48
|
+
violations.push({
|
|
49
|
+
...usageViolation(usage, 'deprecated-intent', `"${usage.intent}" is deprecated.`, []),
|
|
50
|
+
severity: 'warning',
|
|
51
|
+
suggestions: instead ? [{ intent: instead, text: registry.defs[instead].description }] : [],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
for (const check of checksFor(registry, usage.intent)) {
|
|
55
|
+
const impl = tooling.checks?.[check.type];
|
|
56
|
+
if (!impl) {
|
|
57
|
+
violations.push({
|
|
58
|
+
...usageViolation(usage, 'unknown-check', `No "${tooling.name}" check named "${check.type}".`, []),
|
|
59
|
+
severity: 'warning',
|
|
60
|
+
});
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
violations.push(...impl({ check, intentId: usage.intent, intent, usage, ctx }));
|
|
64
|
+
}
|
|
65
|
+
return violations;
|
|
66
|
+
}
|
|
67
|
+
/** Runs every applicable profile's usages, per-intent checks and rules over the files. */
|
|
68
|
+
export function checkFiles(files, registry, toolings) {
|
|
69
|
+
const violations = [];
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
for (const tooling of toolings) {
|
|
72
|
+
if (!tooling.appliesTo(file.path))
|
|
73
|
+
continue;
|
|
74
|
+
const ctx = { registry, file };
|
|
75
|
+
for (const usage of tooling.findUsages(ctx)) {
|
|
76
|
+
violations.push(...checkUsage(ctx, tooling, usage));
|
|
77
|
+
}
|
|
78
|
+
for (const rule of tooling.rules ?? [])
|
|
79
|
+
violations.push(...rule.run(ctx));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const tooling of toolings) {
|
|
83
|
+
if (!tooling.projectRules?.length)
|
|
84
|
+
continue;
|
|
85
|
+
const own = files.filter((f) => tooling.appliesTo(f.path));
|
|
86
|
+
for (const rule of tooling.projectRules)
|
|
87
|
+
violations.push(...rule.run({ registry, files: own }));
|
|
88
|
+
}
|
|
89
|
+
return violations.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.column - b.column);
|
|
90
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AnyRegistry } from '../registry.js';
|
|
2
|
+
export type FoundIntent = {
|
|
3
|
+
id: string;
|
|
4
|
+
score: number;
|
|
5
|
+
/** Set when the query matched another intent's "not for" line that points here. */
|
|
6
|
+
via?: string;
|
|
7
|
+
};
|
|
8
|
+
/** Lower-case words, camelCase split, without stop words. */
|
|
9
|
+
export declare function words(text: string): string[];
|
|
10
|
+
/** A rough stem, so delete/deleting/deleted and invite/invitation meet. */
|
|
11
|
+
export declare function stem(word: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Ranks a registry's call-site intents against a description of what something is for
|
|
14
|
+
* ("wrong email or password", "close the banner"). The meaning in the id counts most,
|
|
15
|
+
* then keywords, then the description; a description that covers the whole question gets
|
|
16
|
+
* a bonus. A question that matches an intent's "not for" line, on words that set that
|
|
17
|
+
* line apart from the intent itself, pushes the intent down and the one it points to up,
|
|
18
|
+
* the way an agent is told to read them. `textOf` adds what tooling knows (`404`, `<h1>`).
|
|
19
|
+
*/
|
|
20
|
+
export declare function findIntents(registry: AnyRegistry, query: string, limit?: number, textOf?: (id: string) => string): FoundIntent[];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Words that say nothing about what something is for. */
|
|
2
|
+
const STOP = new Set('a an the of for to in on at by with from or and is are be it its this that these those my your our their when what which who how not no do does something someone some any every each one'.split(' '));
|
|
3
|
+
/** Lower-case words, camelCase split, without stop words. */
|
|
4
|
+
export function words(text) {
|
|
5
|
+
return text
|
|
6
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.split(/[^a-z0-9]+/)
|
|
9
|
+
.filter((w) => w.length > 1 && !STOP.has(w));
|
|
10
|
+
}
|
|
11
|
+
/** A rough stem, so delete/deleting/deleted and invite/invitation meet. */
|
|
12
|
+
export function stem(word) {
|
|
13
|
+
return word.replace(/(ations?|ings?|ed|es|s|er|ers|ion|ions)$/, '').replace(/e$/, '') || word;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Ranks a registry's call-site intents against a description of what something is for
|
|
17
|
+
* ("wrong email or password", "close the banner"). The meaning in the id counts most,
|
|
18
|
+
* then keywords, then the description; a description that covers the whole question gets
|
|
19
|
+
* a bonus. A question that matches an intent's "not for" line, on words that set that
|
|
20
|
+
* line apart from the intent itself, pushes the intent down and the one it points to up,
|
|
21
|
+
* the way an agent is told to read them. `textOf` adds what tooling knows (`404`, `<h1>`).
|
|
22
|
+
*/
|
|
23
|
+
export function findIntents(registry, query, limit = 3, textOf) {
|
|
24
|
+
const raw = query.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
25
|
+
const terms = new Set(words(query).map(stem));
|
|
26
|
+
if (terms.size === 0)
|
|
27
|
+
return [];
|
|
28
|
+
// "sign out" also asks for `signout`, "signed in" for `signedin`.
|
|
29
|
+
const joined = new Set(raw.slice(1).map((w, i) => stem(raw[i] + w)));
|
|
30
|
+
const asked = new Set([...terms, ...joined]);
|
|
31
|
+
const scores = new Map();
|
|
32
|
+
const bump = (id, by, via) => {
|
|
33
|
+
const current = scores.get(id) ?? { id, score: 0 };
|
|
34
|
+
current.score += by;
|
|
35
|
+
if (via && by > 0)
|
|
36
|
+
current.via = via;
|
|
37
|
+
scores.set(id, current);
|
|
38
|
+
};
|
|
39
|
+
for (const id of registry.ids) {
|
|
40
|
+
if (registry.isInternal(id))
|
|
41
|
+
continue;
|
|
42
|
+
const intent = registry.defs[id];
|
|
43
|
+
const [domain, ...meaning] = id.split('.');
|
|
44
|
+
const meaningWords = new Set(meaning.flatMap(words).map(stem));
|
|
45
|
+
const keywordWords = new Set((intent.keywords ?? []).flatMap(words).map(stem));
|
|
46
|
+
const describedWords = new Set([...words(intent.description), ...words(textOf?.(id) ?? '')].map(stem));
|
|
47
|
+
let score = 0;
|
|
48
|
+
for (const w of asked) {
|
|
49
|
+
if (meaningWords.has(w))
|
|
50
|
+
score += 6;
|
|
51
|
+
else if (keywordWords.has(w))
|
|
52
|
+
score += 4;
|
|
53
|
+
else if (describedWords.has(w))
|
|
54
|
+
score += 2;
|
|
55
|
+
}
|
|
56
|
+
if (asked.has(stem(domain)))
|
|
57
|
+
score += 2;
|
|
58
|
+
const own = new Set([...meaningWords, ...keywordWords, ...describedWords, stem(domain)]);
|
|
59
|
+
const covered = [...terms].filter((w) => own.has(w)).length;
|
|
60
|
+
if (terms.size > 1 && covered === terms.size)
|
|
61
|
+
score += 4;
|
|
62
|
+
if (score > 0)
|
|
63
|
+
bump(id, score);
|
|
64
|
+
// A "not for" line moves the answer only on words that set it apart from this intent.
|
|
65
|
+
for (const e of (intent.notFor ?? [])) {
|
|
66
|
+
const distinct = words(e.context)
|
|
67
|
+
.map(stem)
|
|
68
|
+
.filter((w) => asked.has(w) && !own.has(w));
|
|
69
|
+
const hits = new Set(distinct).size;
|
|
70
|
+
if (hits === 0 || hits / terms.size < 0.5)
|
|
71
|
+
continue;
|
|
72
|
+
bump(id, -hits * 4);
|
|
73
|
+
if (e.instead && registry.has(e.instead) && !registry.isInternal(e.instead))
|
|
74
|
+
bump(e.instead, hits * 5, id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...scores.values()]
|
|
78
|
+
.filter((f) => f.score > 0)
|
|
79
|
+
.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
|
|
80
|
+
.slice(0, limit);
|
|
81
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { checkFiles } from './check.js';
|
|
2
|
+
export { applyBaseline, createBaseline, fingerprint } from './baseline.js';
|
|
3
|
+
export type { Baseline } from './baseline.js';
|
|
4
|
+
export { compileReference, compileSpec } from './spec-md.js';
|
|
5
|
+
export { findIntents } from './find.js';
|
|
6
|
+
export type { FoundIntent } from './find.js';
|
|
7
|
+
export type { CompileSpecOptions } from './spec-md.js';
|
|
8
|
+
export { editDistance, intentsUsing, locate, nearestIntents } from './util.js';
|
|
9
|
+
export type { CheckImpl, ProfileTooling, ProjectRule, Rule, RuleDoc, Severity, SourceFile, SpecContext, SpecTooling, Suggestion, ToolingContext, Usage, Violation, } from './types.js';
|
|
10
|
+
export { CORE_RULE_DOCS } from './rule-docs.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { checkFiles } from './check.js';
|
|
2
|
+
export { applyBaseline, createBaseline, fingerprint } from './baseline.js';
|
|
3
|
+
export { compileReference, compileSpec } from './spec-md.js';
|
|
4
|
+
export { findIntents } from './find.js';
|
|
5
|
+
export { editDistance, intentsUsing, locate, nearestIntents } from './util.js';
|
|
6
|
+
export { CORE_RULE_DOCS } from './rule-docs.js';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Rules the engine applies to every profile's usages. */
|
|
2
|
+
export const CORE_RULE_DOCS = [
|
|
3
|
+
{
|
|
4
|
+
id: 'unknown-intent',
|
|
5
|
+
profile: 'core',
|
|
6
|
+
severity: 'error',
|
|
7
|
+
summary: 'An intent that is not in the registry. Suggests the closest names, so a typo is one step from the fix.',
|
|
8
|
+
bad: "<Action intent=\"action.delet\">Delete</Action>",
|
|
9
|
+
good: "<Action intent=\"action.delete\">Delete</Action>",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: 'internal-intent',
|
|
13
|
+
profile: 'core',
|
|
14
|
+
severity: 'error',
|
|
15
|
+
summary: 'A ref-only intent (a look, a status, a level) used where code should name a meaning. Suggests the meaning intents built on it.',
|
|
16
|
+
bad: '<Action intent="control.primary">Save</Action>',
|
|
17
|
+
good: '<Action intent="action.save">Save</Action>',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'deprecated-intent',
|
|
21
|
+
profile: 'core',
|
|
22
|
+
severity: 'warning',
|
|
23
|
+
summary: 'An intent marked deprecated. Suggests the intent its "not for" line points to.',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'unknown-check',
|
|
27
|
+
profile: 'core',
|
|
28
|
+
severity: 'warning',
|
|
29
|
+
summary: 'An intent asks for a per-intent check its profile does not implement.',
|
|
30
|
+
},
|
|
31
|
+
];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AnyRegistry } from '../registry.js';
|
|
2
|
+
import type { SpecTooling } from './types.js';
|
|
3
|
+
export type CompileSpecOptions = {
|
|
4
|
+
/** Where the registry lives, mentioned in the header so readers know what to edit. */
|
|
5
|
+
registryPath?: string;
|
|
6
|
+
/** How to run the CLI in this project. */
|
|
7
|
+
cli?: string;
|
|
8
|
+
/** The import path of the project's components, e.g. `@/components/prongs`. */
|
|
9
|
+
componentsImport?: string;
|
|
10
|
+
/**
|
|
11
|
+
* A second file for what only changing the system needs (theme, look intents). With it,
|
|
12
|
+
* PRONGS.md keeps what building needs and points there; without it, everything is in one file.
|
|
13
|
+
*/
|
|
14
|
+
referenceFile?: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Builds PRONGS.md: the index agents choose intents from, the profiles' own rules and
|
|
18
|
+
* examples, then the look-only intents new intents are built from. It leaves out
|
|
19
|
+
* resolved class strings: agents never write them, and the registry already has them.
|
|
20
|
+
* Works for any profile; tooling adds its own sections.
|
|
21
|
+
*/
|
|
22
|
+
export declare function compileSpec(registry: AnyRegistry, toolings: readonly SpecTooling[], options?: CompileSpecOptions): string;
|
|
23
|
+
/** The reference file: what editing the theme or adding intents needs, and building does not. */
|
|
24
|
+
export declare function compileReference(registry: AnyRegistry, toolings: readonly SpecTooling[], options?: CompileSpecOptions): string;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { domainOf } from '../spec.js';
|
|
2
|
+
/**
|
|
3
|
+
* Builds PRONGS.md: the index agents choose intents from, the profiles' own rules and
|
|
4
|
+
* examples, then the look-only intents new intents are built from. It leaves out
|
|
5
|
+
* resolved class strings: agents never write them, and the registry already has them.
|
|
6
|
+
* Works for any profile; tooling adds its own sections.
|
|
7
|
+
*/
|
|
8
|
+
export function compileSpec(registry, toolings, options = {}) {
|
|
9
|
+
const cli = options.cli ?? 'npx prongs';
|
|
10
|
+
const toolingFor = (id) => toolings.find((t) => t.name === registry.profileOf(id).name);
|
|
11
|
+
const out = [];
|
|
12
|
+
out.push('# PRONGS.md', '');
|
|
13
|
+
out.push(`Generated from \`${options.registryPath ?? 'the intent registry'}\` by \`${cli} spec\`. Do not edit by hand.`, '');
|
|
14
|
+
out.push('## How to choose an intent', '', '1. Pick the intent whose description matches what the element or code is *for*, not how it should look.', '2. Read its "not for" lines. If one matches your case, use the intent it points to.', `3. If nothing fits, add one instead of working around the system: \`${cli} add-intent <domain.meaning> --description "..." --refs <closest>\`.`, `4. \`${cli} check\` verifies usage. In projects with hooks installed it runs after every edit.`, '');
|
|
15
|
+
out.push('## Intents', '');
|
|
16
|
+
const callSite = registry.ids.filter((id) => !registry.isInternal(id));
|
|
17
|
+
const groups = new Map();
|
|
18
|
+
for (const id of callSite) {
|
|
19
|
+
const group = toolingFor(id)?.groupOf?.(id) ?? `\`${domainOf(id)}\``;
|
|
20
|
+
groups.set(group, [...(groups.get(group) ?? []), id]);
|
|
21
|
+
}
|
|
22
|
+
for (const [group, ids] of groups) {
|
|
23
|
+
out.push(`### ${group}`, '');
|
|
24
|
+
for (const id of ids) {
|
|
25
|
+
const intent = registry.defs[id];
|
|
26
|
+
const flag = intent.status && intent.status !== 'stable' ? ` _(${intent.status})_` : '';
|
|
27
|
+
const renders = toolingFor(id)?.describe?.(registry.resolve(id).binding);
|
|
28
|
+
out.push(`- \`${id}\`${renders ? ` ${renders}` : ''}${flag}: ${intent.description}`);
|
|
29
|
+
if (intent.keywords?.length)
|
|
30
|
+
out.push(` - matches code that says: ${intent.keywords.join(', ')}`);
|
|
31
|
+
for (const e of (intent.notFor ?? [])) {
|
|
32
|
+
out.push(` - not for ${e.context}${e.instead ? `: use \`${e.instead}\`` : ''}${e.reason ? ` (${e.reason})` : ''}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
out.push('');
|
|
36
|
+
}
|
|
37
|
+
for (const tooling of toolings) {
|
|
38
|
+
const section = tooling.specSection?.(registry, { cli, componentsImport: options.componentsImport });
|
|
39
|
+
if (section)
|
|
40
|
+
out.push(section.trim(), '');
|
|
41
|
+
}
|
|
42
|
+
if (options.referenceFile) {
|
|
43
|
+
out.push('## Changing the system', '', `To edit the theme or add an intent, read \`${options.referenceFile}\` (colours, surfaces, the look intents to use as \`refs\`). Building UI does not need it.`, '');
|
|
44
|
+
return out.join('\n');
|
|
45
|
+
}
|
|
46
|
+
out.push(...referenceParts(registry, toolings, options, cli));
|
|
47
|
+
return out.join('\n');
|
|
48
|
+
}
|
|
49
|
+
/** The reference file: what editing the theme or adding intents needs, and building does not. */
|
|
50
|
+
export function compileReference(registry, toolings, options = {}) {
|
|
51
|
+
const cli = options.cli ?? 'npx prongs';
|
|
52
|
+
return [
|
|
53
|
+
`# ${options.referenceFile ?? 'PRONGS.reference.md'}`,
|
|
54
|
+
'',
|
|
55
|
+
`Generated from \`${options.registryPath ?? 'the intent registry'}\` by \`${cli} spec\`. Do not edit by hand. For building UI, read PRONGS.md instead.`,
|
|
56
|
+
'',
|
|
57
|
+
...referenceParts(registry, toolings, options, cli),
|
|
58
|
+
].join('\n');
|
|
59
|
+
}
|
|
60
|
+
function referenceParts(registry, toolings, options, cli) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const tooling of toolings) {
|
|
63
|
+
const section = tooling.referenceSection?.(registry, { cli, componentsImport: options.componentsImport });
|
|
64
|
+
if (section)
|
|
65
|
+
out.push(section.trim(), '');
|
|
66
|
+
}
|
|
67
|
+
const look = registry.ids.filter((id) => registry.isInternal(id));
|
|
68
|
+
if (look.length) {
|
|
69
|
+
out.push('## Look intents', '');
|
|
70
|
+
out.push(`Ref-only building blocks. Never use them at call sites; list them in \`refs\` when you add an intent. Their classes are in \`${options.registryPath ?? 'the registry'}\`.`, '');
|
|
71
|
+
for (const id of look)
|
|
72
|
+
out.push(`- \`${id}\`: ${registry.defs[id].description}`);
|
|
73
|
+
out.push('');
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Check, Intent } from '../spec.js';
|
|
2
|
+
import type { AnyRegistry } from '../registry.js';
|
|
3
|
+
/** A source file, with `path` relative to the project root using forward slashes. */
|
|
4
|
+
export type SourceFile = {
|
|
5
|
+
path: string;
|
|
6
|
+
text: string;
|
|
7
|
+
};
|
|
8
|
+
export type Severity = 'error' | 'warning' | 'info';
|
|
9
|
+
export type Suggestion = {
|
|
10
|
+
text: string;
|
|
11
|
+
intent?: string;
|
|
12
|
+
component?: string;
|
|
13
|
+
/** A ready-to-run command, e.g. `npx prongs add-intent ...`. */
|
|
14
|
+
command?: string;
|
|
15
|
+
};
|
|
16
|
+
/** What the spec knows about the project, for tooling that writes its own sections. */
|
|
17
|
+
export type SpecContext = {
|
|
18
|
+
/** How to run the CLI in this project. */
|
|
19
|
+
cli: string;
|
|
20
|
+
/** The import path of the project's components, e.g. `@/components/prongs`. */
|
|
21
|
+
componentsImport?: string;
|
|
22
|
+
};
|
|
23
|
+
export type Violation = {
|
|
24
|
+
rule: string;
|
|
25
|
+
severity: Severity;
|
|
26
|
+
path: string;
|
|
27
|
+
line: number;
|
|
28
|
+
column: number;
|
|
29
|
+
message: string;
|
|
30
|
+
/** The source line, trimmed. Used for baselines, so it must not include line numbers. */
|
|
31
|
+
snippet: string;
|
|
32
|
+
intent?: string;
|
|
33
|
+
suggestions: Suggestion[];
|
|
34
|
+
/** Rule-specific details, e.g. offending classes. Used by --suggest. */
|
|
35
|
+
data?: Record<string, unknown>;
|
|
36
|
+
};
|
|
37
|
+
/** One place in code where an intent is used. */
|
|
38
|
+
export type Usage = {
|
|
39
|
+
intent: string;
|
|
40
|
+
path: string;
|
|
41
|
+
line: number;
|
|
42
|
+
column: number;
|
|
43
|
+
snippet: string;
|
|
44
|
+
/** What the intent was used through, e.g. a component name. Profile-defined. */
|
|
45
|
+
via?: string;
|
|
46
|
+
};
|
|
47
|
+
export type ToolingContext = {
|
|
48
|
+
registry: AnyRegistry;
|
|
49
|
+
file: SourceFile;
|
|
50
|
+
};
|
|
51
|
+
/** What a rule finds, written for people and agents: the docs' check-rules page reads these. */
|
|
52
|
+
export type RuleDoc = {
|
|
53
|
+
id: string;
|
|
54
|
+
/** The profile whose tooling reports it, or `core` for rules every profile shares. */
|
|
55
|
+
profile: string;
|
|
56
|
+
severity: Severity;
|
|
57
|
+
/** What it catches, in a sentence or two. May use `backticks`. */
|
|
58
|
+
summary: string;
|
|
59
|
+
/** Code that triggers it, and the fix. */
|
|
60
|
+
bad?: string;
|
|
61
|
+
good?: string;
|
|
62
|
+
};
|
|
63
|
+
export type Rule = {
|
|
64
|
+
id: string;
|
|
65
|
+
run(ctx: ToolingContext): Violation[];
|
|
66
|
+
};
|
|
67
|
+
/** A rule over the whole project at once, for patterns no single file shows (a tweak repeated across pages). */
|
|
68
|
+
export type ProjectRule = {
|
|
69
|
+
id: string;
|
|
70
|
+
run(ctx: {
|
|
71
|
+
registry: AnyRegistry;
|
|
72
|
+
files: readonly SourceFile[];
|
|
73
|
+
}): Violation[];
|
|
74
|
+
};
|
|
75
|
+
export type CheckImpl = (args: {
|
|
76
|
+
check: Check;
|
|
77
|
+
intentId: string;
|
|
78
|
+
intent: Intent;
|
|
79
|
+
usage: Usage;
|
|
80
|
+
ctx: ToolingContext;
|
|
81
|
+
}) => Violation[];
|
|
82
|
+
/**
|
|
83
|
+
* The tooling half of a profile: how its intents show up in code and how to
|
|
84
|
+
* verify them. Never shipped to the browser.
|
|
85
|
+
*/
|
|
86
|
+
/** The part of a profile's tooling that writes PRONGS.md, usable without the code analysis. */
|
|
87
|
+
export type SpecTooling = Pick<ProfileTooling, 'name' | 'describe' | 'specSection' | 'referenceSection' | 'groupOf' | 'usage'>;
|
|
88
|
+
export type ProfileTooling = {
|
|
89
|
+
/** Must match the runtime Profile's name. */
|
|
90
|
+
name: string;
|
|
91
|
+
appliesTo(path: string): boolean;
|
|
92
|
+
findUsages(ctx: ToolingContext): Usage[];
|
|
93
|
+
/** File-level rules, e.g. "no raw <button>". */
|
|
94
|
+
rules?: Rule[];
|
|
95
|
+
/** Rules over every file the tooling applies to, run once per check. */
|
|
96
|
+
projectRules?: ProjectRule[];
|
|
97
|
+
/** Per-intent check implementations, keyed by Check.type. */
|
|
98
|
+
checks?: Record<string, CheckImpl>;
|
|
99
|
+
/** What a resolved binding produces (element, role, type), shown next to the intent in the spec's index. */
|
|
100
|
+
describe?(binding: unknown): string;
|
|
101
|
+
/** Extra markdown placed in the spec after the intent index. */
|
|
102
|
+
specSection?(registry: AnyRegistry, context: SpecContext): string;
|
|
103
|
+
/** Markdown only needed to change the system (theme, new intents): goes to the reference file when there is one. */
|
|
104
|
+
referenceSection?(registry: AnyRegistry, context: SpecContext): string;
|
|
105
|
+
/** Groups call-site intents in the index, e.g. by component. Defaults to domain. */
|
|
106
|
+
groupOf?(id: string): string;
|
|
107
|
+
/** One line showing how code uses an intent, for `prongs find`: `<Action intent="action.delete">…</Action>`. */
|
|
108
|
+
usage?(id: string, binding: unknown): string;
|
|
109
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AnyRegistry } from '../registry.js';
|
|
2
|
+
export declare function locate(text: string, offset: number): {
|
|
3
|
+
line: number;
|
|
4
|
+
column: number;
|
|
5
|
+
snippet: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function editDistance(a: string, b: string): number;
|
|
8
|
+
/** Intent ids closest to `query` by spelling, call-site intents only. */
|
|
9
|
+
export declare function nearestIntents(registry: AnyRegistry, query: string, limit?: number): string[];
|
|
10
|
+
/** Call-site intents that reference `id`, directly or through other intents. */
|
|
11
|
+
export declare function intentsUsing(registry: AnyRegistry, id: string): string[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export function locate(text, offset) {
|
|
2
|
+
let line = 1;
|
|
3
|
+
let lineStart = 0;
|
|
4
|
+
for (let i = 0; i < offset && i < text.length; i++) {
|
|
5
|
+
if (text[i] === '\n') {
|
|
6
|
+
line++;
|
|
7
|
+
lineStart = i + 1;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const lineEnd = text.indexOf('\n', lineStart);
|
|
11
|
+
const snippet = text.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
|
|
12
|
+
return { line, column: offset - lineStart + 1, snippet };
|
|
13
|
+
}
|
|
14
|
+
export function editDistance(a, b) {
|
|
15
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
16
|
+
for (let i = 1; i <= a.length; i++) {
|
|
17
|
+
let diag = prev[0];
|
|
18
|
+
prev[0] = i;
|
|
19
|
+
for (let j = 1; j <= b.length; j++) {
|
|
20
|
+
const temp = prev[j];
|
|
21
|
+
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
22
|
+
diag = temp;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return prev[b.length];
|
|
26
|
+
}
|
|
27
|
+
/** Intent ids closest to `query` by spelling, call-site intents only. */
|
|
28
|
+
export function nearestIntents(registry, query, limit = 3) {
|
|
29
|
+
return registry.ids
|
|
30
|
+
.filter((id) => !registry.isInternal(id))
|
|
31
|
+
.map((id) => ({ id, d: editDistance(query, id) }))
|
|
32
|
+
.filter(({ id, d }) => d <= Math.max(3, Math.floor(id.length / 3)))
|
|
33
|
+
.sort((a, b) => a.d - b.d || a.id.localeCompare(b.id))
|
|
34
|
+
.slice(0, limit)
|
|
35
|
+
.map(({ id }) => id);
|
|
36
|
+
}
|
|
37
|
+
/** Call-site intents that reference `id`, directly or through other intents. */
|
|
38
|
+
export function intentsUsing(registry, id) {
|
|
39
|
+
const users = new Set();
|
|
40
|
+
const visit = (target) => {
|
|
41
|
+
for (const candidate of registry.ids) {
|
|
42
|
+
if (users.has(candidate))
|
|
43
|
+
continue;
|
|
44
|
+
if ((registry.defs[candidate].refs ?? []).includes(target)) {
|
|
45
|
+
users.add(candidate);
|
|
46
|
+
visit(candidate);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
visit(id);
|
|
51
|
+
return [...users].filter((u) => !registry.isInternal(u)).sort();
|
|
52
|
+
}
|
package/dist/export.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AnyRegistry } from './registry.js';
|
|
2
|
+
import type { IntentDefs } from './spec.js';
|
|
3
|
+
/** The version of the Prongs Intent Spec this package implements. */
|
|
4
|
+
export declare const SPEC_VERSION = "draft";
|
|
5
|
+
export type RegistryExport = {
|
|
6
|
+
spec: string;
|
|
7
|
+
profiles: {
|
|
8
|
+
name: string;
|
|
9
|
+
domains: string[];
|
|
10
|
+
internalDomains?: string[];
|
|
11
|
+
}[];
|
|
12
|
+
intents: IntentDefs;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A registry as JSON, for tools in other languages (spec §7): the profiles' names and domains,
|
|
16
|
+
* not their code, and every intent as written. Bindings that hold functions lose them.
|
|
17
|
+
*/
|
|
18
|
+
export declare function exportRegistry(registry: AnyRegistry): RegistryExport;
|
package/dist/export.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The version of the Prongs Intent Spec this package implements. */
|
|
2
|
+
export const SPEC_VERSION = 'draft';
|
|
3
|
+
/**
|
|
4
|
+
* A registry as JSON, for tools in other languages (spec §7): the profiles' names and domains,
|
|
5
|
+
* not their code, and every intent as written. Bindings that hold functions lose them.
|
|
6
|
+
*/
|
|
7
|
+
export function exportRegistry(registry) {
|
|
8
|
+
return {
|
|
9
|
+
spec: SPEC_VERSION,
|
|
10
|
+
profiles: registry.profiles.map((p) => ({
|
|
11
|
+
name: p.name,
|
|
12
|
+
domains: [...p.domains],
|
|
13
|
+
...(p.internalDomains?.length ? { internalDomains: [...p.internalDomains] } : {}),
|
|
14
|
+
})),
|
|
15
|
+
intents: JSON.parse(JSON.stringify(registry.defs)),
|
|
16
|
+
};
|
|
17
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { Check, Intent, IntentDefs, NotFor, IntentKind, IntentStatus, Profile, ProfileIssue, ResolvedIntent, } from './spec.js';
|
|
2
|
+
export { ID_PATTERN, META_KEY_PATTERN, domainOf, isReservedMetaKey } from './spec.js';
|
|
3
|
+
export { createRegistry, RegistryError } from './registry.js';
|
|
4
|
+
export type { AnyRegistry, CreateRegistryOptions, IntentIn, IntentOf, Registry } from './registry.js';
|
|
5
|
+
export { SPEC_VERSION, exportRegistry } from './export.js';
|
|
6
|
+
export type { RegistryExport } from './export.js';
|
|
7
|
+
export { validateRegistry } from './validate.js';
|
|
8
|
+
export type { RegistryIssue, RegistryIssueCode } from './validate.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type IntentDefs, type Profile, type ResolvedIntent } from './spec.js';
|
|
2
|
+
import { type RegistryIssue } from './validate.js';
|
|
3
|
+
export type Registry<T extends IntentDefs = IntentDefs> = {
|
|
4
|
+
readonly defs: T;
|
|
5
|
+
readonly profiles: readonly Profile[];
|
|
6
|
+
readonly ids: readonly (keyof T & string)[];
|
|
7
|
+
has(id: string): id is keyof T & string;
|
|
8
|
+
profileOf(id: string): Profile;
|
|
9
|
+
/** True when the id belongs to a domain that may only be used as a ref. */
|
|
10
|
+
isInternal(id: string): boolean;
|
|
11
|
+
resolve<B = any>(id: keyof T & string): ResolvedIntent<B>;
|
|
12
|
+
};
|
|
13
|
+
/** A registry of any shape; what tooling accepts. */
|
|
14
|
+
export type AnyRegistry = Registry<any>;
|
|
15
|
+
/** Every intent id in a registry, as a literal union. */
|
|
16
|
+
export type IntentOf<R> = R extends Registry<infer T> ? keyof T & string : never;
|
|
17
|
+
/** Intent ids in the given domain(s): IntentIn<typeof registry, 'action'>. */
|
|
18
|
+
export type IntentIn<R, D extends string> = Extract<IntentOf<R>, `${D}.${string}`>;
|
|
19
|
+
export type CreateRegistryOptions = {
|
|
20
|
+
profiles: readonly Profile[];
|
|
21
|
+
/** Validate definitions on creation. Defaults to true outside production. */
|
|
22
|
+
validate?: boolean;
|
|
23
|
+
};
|
|
24
|
+
export declare class RegistryError extends Error {
|
|
25
|
+
readonly issues: RegistryIssue[];
|
|
26
|
+
constructor(issues: RegistryIssue[]);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Wraps plain intent definitions into a typed registry. Pass the object inline
|
|
30
|
+
* (or `as const`) so intent ids are inferred as literal types.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createRegistry<const T extends IntentDefs>(defs: T, options: CreateRegistryOptions): Registry<T>;
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { domainOf } from './spec.js';
|
|
2
|
+
import { profileForDomain, validateRegistry } from './validate.js';
|
|
3
|
+
export class RegistryError extends Error {
|
|
4
|
+
issues;
|
|
5
|
+
constructor(issues) {
|
|
6
|
+
super(`[prongs] Invalid intent registry:\n${issues.map((i) => ` - ${i.id}: ${i.message}`).join('\n')}`);
|
|
7
|
+
this.issues = issues;
|
|
8
|
+
this.name = 'RegistryError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function isDev() {
|
|
12
|
+
try {
|
|
13
|
+
return process.env.NODE_ENV !== 'production';
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Wraps plain intent definitions into a typed registry. Pass the object inline
|
|
21
|
+
* (or `as const`) so intent ids are inferred as literal types.
|
|
22
|
+
*/
|
|
23
|
+
export function createRegistry(defs, options) {
|
|
24
|
+
const { profiles } = options;
|
|
25
|
+
if (options.validate ?? isDev()) {
|
|
26
|
+
const errors = validateRegistry(defs, profiles).filter((i) => i.severity === 'error');
|
|
27
|
+
if (errors.length > 0)
|
|
28
|
+
throw new RegistryError(errors);
|
|
29
|
+
}
|
|
30
|
+
const cache = new Map();
|
|
31
|
+
const profileOf = (id) => {
|
|
32
|
+
const profile = profileForDomain(profiles, domainOf(id));
|
|
33
|
+
if (!profile)
|
|
34
|
+
throw new Error(`[prongs] No profile owns the "${domainOf(id)}" domain (intent "${id}").`);
|
|
35
|
+
return profile;
|
|
36
|
+
};
|
|
37
|
+
const resolveBinding = (id, visiting) => {
|
|
38
|
+
const intent = defs[id];
|
|
39
|
+
if (!intent) {
|
|
40
|
+
throw new Error(`[prongs] Unknown intent "${id}". See PRONGS.md for the available intents.`);
|
|
41
|
+
}
|
|
42
|
+
if (visiting.has(id))
|
|
43
|
+
throw new Error(`[prongs] Circular intent reference at "${id}".`);
|
|
44
|
+
visiting.add(id);
|
|
45
|
+
const profile = profileOf(id);
|
|
46
|
+
let binding;
|
|
47
|
+
for (const ref of intent.refs ?? []) {
|
|
48
|
+
binding = profile.merge(binding, resolveBinding(ref, visiting));
|
|
49
|
+
}
|
|
50
|
+
binding = profile.merge(binding, intent.binding);
|
|
51
|
+
visiting.delete(id);
|
|
52
|
+
return binding;
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
defs,
|
|
56
|
+
profiles,
|
|
57
|
+
ids: Object.keys(defs),
|
|
58
|
+
has: (id) => Object.prototype.hasOwnProperty.call(defs, id),
|
|
59
|
+
profileOf,
|
|
60
|
+
isInternal: (id) => profileOf(id).internalDomains?.includes(domainOf(id)) ?? false,
|
|
61
|
+
resolve(id) {
|
|
62
|
+
let resolved = cache.get(id);
|
|
63
|
+
if (!resolved) {
|
|
64
|
+
resolved = { id, profile: profileOf(id).name, binding: resolveBinding(id, new Set()) };
|
|
65
|
+
cache.set(id, resolved);
|
|
66
|
+
}
|
|
67
|
+
return resolved;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/dist/spec.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Prongs intent spec.
|
|
3
|
+
*
|
|
4
|
+
* An intent is a named choice inside a codebase: what it is for (so an agent can
|
|
5
|
+
* pick it), what it resolves to (its binding), when not to use it, and how to
|
|
6
|
+
* verify it was followed. Core knows nothing about any domain: UI, data access,
|
|
7
|
+
* routes and so on plug in as profiles that own a set of id prefixes.
|
|
8
|
+
*/
|
|
9
|
+
export type IntentKind = 'primitive' | 'semantic';
|
|
10
|
+
export type IntentStatus = 'stable' | 'beta' | 'experimental' | 'deprecated';
|
|
11
|
+
/** When NOT to use an intent, and what to use instead. Drives selection. */
|
|
12
|
+
export type NotFor = {
|
|
13
|
+
context: string;
|
|
14
|
+
reason?: string;
|
|
15
|
+
instead?: string;
|
|
16
|
+
};
|
|
17
|
+
/** A per-intent check. `type` is defined by the owning profile's tooling. */
|
|
18
|
+
export type Check = {
|
|
19
|
+
type: string;
|
|
20
|
+
} & Record<string, unknown>;
|
|
21
|
+
export type Intent<B = unknown, C extends Check = Check> = {
|
|
22
|
+
kind: IntentKind;
|
|
23
|
+
/** One sentence: what this intent is FOR, not what it looks like. Required. */
|
|
24
|
+
description: string;
|
|
25
|
+
/** Semantic intents inherit from these, in order. Same profile only. */
|
|
26
|
+
refs?: readonly string[];
|
|
27
|
+
/** What the intent resolves to. Shape owned by the profile. */
|
|
28
|
+
binding?: B;
|
|
29
|
+
notFor?: readonly NotFor[];
|
|
30
|
+
/**
|
|
31
|
+
* Words in code that point to this intent: handler names, labels, prop names.
|
|
32
|
+
* e.g. action.bookmark: ['save', 'bookmark']. Tooling uses them to spot an intent
|
|
33
|
+
* that disagrees with the code it is used in; agents can read them as hints.
|
|
34
|
+
*/
|
|
35
|
+
keywords?: readonly string[];
|
|
36
|
+
checks?: readonly C[];
|
|
37
|
+
status?: IntentStatus;
|
|
38
|
+
/** Written by tooling (e.g. trial results), never by hand. Shape not yet fixed. */
|
|
39
|
+
evidence?: Readonly<Record<string, unknown>>;
|
|
40
|
+
/**
|
|
41
|
+
* Data the spec does not define, for teams and tools: an owner, a design link, a ticket.
|
|
42
|
+
* Keys are prefixed, `com.example/owner`, so two tools never claim the same key.
|
|
43
|
+
*/
|
|
44
|
+
meta?: Readonly<Record<string, unknown>>;
|
|
45
|
+
};
|
|
46
|
+
export type IntentDefs = Record<string, Intent<any, any>>;
|
|
47
|
+
/** A validation finding. Errors make a registry invalid; warnings are reported but allowed. */
|
|
48
|
+
export type ProfileIssue = string | {
|
|
49
|
+
message: string;
|
|
50
|
+
severity: 'error' | 'warning';
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* A profile defines what a binding means for the domains it owns.
|
|
54
|
+
* This is the runtime half; it ships with the app, so keep it small.
|
|
55
|
+
* Tooling (finding usages, rules) lives in a separate ProfileTooling object.
|
|
56
|
+
*/
|
|
57
|
+
export type Profile<B = any> = {
|
|
58
|
+
name: string;
|
|
59
|
+
/** Id prefixes this profile owns, e.g. ['action', 'control']. */
|
|
60
|
+
domains: readonly string[];
|
|
61
|
+
/** Domains that may only be used as `refs` targets, never at a call site. */
|
|
62
|
+
internalDomains?: readonly string[];
|
|
63
|
+
/** Returns problems with one intent's binding or refs. Plain strings are errors. */
|
|
64
|
+
validate?(id: string, intent: Intent<B>, defs: IntentDefs): ProfileIssue[];
|
|
65
|
+
/** Rules about the vocabulary as a whole, e.g. "neighbouring intents must contrast". */
|
|
66
|
+
validateAll?(defs: IntentDefs): {
|
|
67
|
+
id: string;
|
|
68
|
+
message: string;
|
|
69
|
+
severity: 'error' | 'warning';
|
|
70
|
+
}[];
|
|
71
|
+
/** Layers `next` over `base`. Must accept undefined for either. */
|
|
72
|
+
merge(base: B | undefined, next: B | undefined): B;
|
|
73
|
+
};
|
|
74
|
+
export type ResolvedIntent<B = unknown> = {
|
|
75
|
+
id: string;
|
|
76
|
+
profile: string;
|
|
77
|
+
binding: B;
|
|
78
|
+
};
|
|
79
|
+
export declare const ID_PATTERN: RegExp;
|
|
80
|
+
/** A `meta` key: a reverse-DNS prefix and a name, `com.example/owner`. */
|
|
81
|
+
export declare const META_KEY_PATTERN: RegExp;
|
|
82
|
+
/** True for `meta` prefixes the spec keeps for itself: any whose second label is `prongs` (`dev.prongs/`). */
|
|
83
|
+
export declare function isReservedMetaKey(key: string): boolean;
|
|
84
|
+
export declare function domainOf(id: string): string;
|
package/dist/spec.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Prongs intent spec.
|
|
3
|
+
*
|
|
4
|
+
* An intent is a named choice inside a codebase: what it is for (so an agent can
|
|
5
|
+
* pick it), what it resolves to (its binding), when not to use it, and how to
|
|
6
|
+
* verify it was followed. Core knows nothing about any domain: UI, data access,
|
|
7
|
+
* routes and so on plug in as profiles that own a set of id prefixes.
|
|
8
|
+
*/
|
|
9
|
+
export const ID_PATTERN = /^[a-z]+(\.[a-z]+){1,2}$/;
|
|
10
|
+
/** A `meta` key: a reverse-DNS prefix and a name, `com.example/owner`. */
|
|
11
|
+
export const META_KEY_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+\/[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
12
|
+
/** True for `meta` prefixes the spec keeps for itself: any whose second label is `prongs` (`dev.prongs/`). */
|
|
13
|
+
export function isReservedMetaKey(key) {
|
|
14
|
+
return key.split('/')[0].split('.')[1] === 'prongs';
|
|
15
|
+
}
|
|
16
|
+
export function domainOf(id) {
|
|
17
|
+
return id.split('.')[0];
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type IntentDefs, type Profile } from './spec.js';
|
|
2
|
+
/**
|
|
3
|
+
* What a registry problem is, stable across implementations and wording: the spec's
|
|
4
|
+
* conformance fixtures compare these, not messages.
|
|
5
|
+
*/
|
|
6
|
+
export type RegistryIssueCode = 'domain-conflict' | 'internal-domain-not-owned' | 'id-grammar' | 'no-profile' | 'description-missing' | 'primitive-refs' | 'semantic-no-refs' | 'unknown-ref' | 'cross-profile-ref' | 'unknown-instead' | 'ref-cycle' | 'meta-key' | 'meta-reserved' | 'deprecated-no-instead' | 'duplicate-keyword' | 'profile';
|
|
7
|
+
export type RegistryIssue = {
|
|
8
|
+
id: string;
|
|
9
|
+
message: string;
|
|
10
|
+
severity: 'error' | 'warning';
|
|
11
|
+
code: RegistryIssueCode;
|
|
12
|
+
};
|
|
13
|
+
export declare function profileForDomain(profiles: readonly Profile[], domain: string): Profile | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Checks a set of intent definitions against the spec and their profiles.
|
|
16
|
+
* Returns every problem rather than stopping at the first.
|
|
17
|
+
*/
|
|
18
|
+
export declare function validateRegistry(defs: IntentDefs, profiles: readonly Profile[]): RegistryIssue[];
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { ID_PATTERN, META_KEY_PATTERN, domainOf, isReservedMetaKey } from './spec.js';
|
|
2
|
+
export function profileForDomain(profiles, domain) {
|
|
3
|
+
return profiles.find((p) => p.domains.includes(domain));
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Checks a set of intent definitions against the spec and their profiles.
|
|
7
|
+
* Returns every problem rather than stopping at the first.
|
|
8
|
+
*/
|
|
9
|
+
export function validateRegistry(defs, profiles) {
|
|
10
|
+
const issues = [];
|
|
11
|
+
const add = (code, id, message, severity = 'error') => issues.push({ id, message, severity, code });
|
|
12
|
+
const seenDomains = new Map();
|
|
13
|
+
for (const profile of profiles) {
|
|
14
|
+
for (const domain of profile.domains) {
|
|
15
|
+
const owner = seenDomains.get(domain);
|
|
16
|
+
if (owner)
|
|
17
|
+
add('domain-conflict', domain, `domain "${domain}" is owned by both "${owner}" and "${profile.name}" profiles`);
|
|
18
|
+
seenDomains.set(domain, profile.name);
|
|
19
|
+
}
|
|
20
|
+
for (const domain of profile.internalDomains ?? []) {
|
|
21
|
+
if (!profile.domains.includes(domain)) {
|
|
22
|
+
add('internal-domain-not-owned', domain, `profile "${profile.name}" lists internal domain "${domain}" that it does not own`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const [id, intent] of Object.entries(defs)) {
|
|
27
|
+
if (!ID_PATTERN.test(id)) {
|
|
28
|
+
add('id-grammar', id, 'id must look like domain.meaning or domain.meaning.variant (lowercase letters only)');
|
|
29
|
+
}
|
|
30
|
+
const profile = profileForDomain(profiles, domainOf(id));
|
|
31
|
+
if (!profile) {
|
|
32
|
+
add('no-profile', id, `no profile owns the "${domainOf(id)}" domain`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (!intent.description || !intent.description.trim()) {
|
|
36
|
+
add('description-missing', id, 'description is required: it is what agents choose intents by');
|
|
37
|
+
}
|
|
38
|
+
const refs = intent.refs ?? [];
|
|
39
|
+
if (intent.kind === 'primitive' && refs.length > 0) {
|
|
40
|
+
add('primitive-refs', id, 'primitive intents cannot have refs; use kind "semantic"');
|
|
41
|
+
}
|
|
42
|
+
if (intent.kind === 'semantic' && refs.length === 0) {
|
|
43
|
+
add('semantic-no-refs', id, 'semantic intents need at least one ref');
|
|
44
|
+
}
|
|
45
|
+
for (const ref of refs) {
|
|
46
|
+
if (!(ref in defs))
|
|
47
|
+
add('unknown-ref', id, `refs unknown intent "${ref}"`);
|
|
48
|
+
else if (profileForDomain(profiles, domainOf(ref)) !== profile) {
|
|
49
|
+
add('cross-profile-ref', id, `refs "${ref}", which belongs to a different profile`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const exception of intent.notFor ?? []) {
|
|
53
|
+
if (exception.instead && !(exception.instead in defs)) {
|
|
54
|
+
add('unknown-instead', id, `notFor points to unknown intent "${exception.instead}"`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (intent.status === 'deprecated' && !(intent.notFor ?? []).some((e) => e.instead)) {
|
|
58
|
+
add('deprecated-no-instead', id, 'deprecated, but no notFor says what to use instead', 'warning');
|
|
59
|
+
}
|
|
60
|
+
for (const key of Object.keys(intent.meta ?? {})) {
|
|
61
|
+
if (!META_KEY_PATTERN.test(key)) {
|
|
62
|
+
add('meta-key', id, `meta key "${key}" needs a reverse-DNS prefix, like "com.example/${key.split('/').pop()}"`);
|
|
63
|
+
}
|
|
64
|
+
else if (isReservedMetaKey(key)) {
|
|
65
|
+
add('meta-reserved', id, `meta key "${key}" uses a prefix the spec reserves, and the spec defines no such key`, 'warning');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const issue of profile.validate?.(id, intent, defs) ?? []) {
|
|
69
|
+
if (typeof issue === 'string')
|
|
70
|
+
add('profile', id, issue);
|
|
71
|
+
else
|
|
72
|
+
add('profile', id, issue.message, issue.severity);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const profile of profiles) {
|
|
76
|
+
const owned = Object.fromEntries(Object.entries(defs).filter(([id]) => profileForDomain(profiles, domainOf(id)) === profile));
|
|
77
|
+
for (const issue of profile.validateAll?.(owned) ?? [])
|
|
78
|
+
issues.push({ ...issue, code: 'profile' });
|
|
79
|
+
}
|
|
80
|
+
// Keywords tell the intents of a domain apart. One claimed twice gives check and agents two answers.
|
|
81
|
+
const claimed = new Map();
|
|
82
|
+
for (const [id, intent] of Object.entries(defs)) {
|
|
83
|
+
for (const keyword of intent.keywords ?? []) {
|
|
84
|
+
const key = `${domainOf(id)}:${keyword}`;
|
|
85
|
+
const other = claimed.get(key);
|
|
86
|
+
if (other)
|
|
87
|
+
add('duplicate-keyword', id, `keyword "${keyword}" is also on "${other}"; keep it on the one it means`, 'warning');
|
|
88
|
+
else
|
|
89
|
+
claimed.set(key, id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const cycle of findCycles(defs)) {
|
|
93
|
+
add('ref-cycle', cycle[0], `circular refs: ${cycle.join(' -> ')}`);
|
|
94
|
+
}
|
|
95
|
+
return issues;
|
|
96
|
+
}
|
|
97
|
+
function findCycles(defs) {
|
|
98
|
+
const cycles = [];
|
|
99
|
+
const state = new Map();
|
|
100
|
+
const visit = (id, path) => {
|
|
101
|
+
if (state.get(id) === 'done' || !(id in defs))
|
|
102
|
+
return;
|
|
103
|
+
if (state.get(id) === 'visiting') {
|
|
104
|
+
cycles.push([...path.slice(path.indexOf(id)), id]);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
state.set(id, 'visiting');
|
|
108
|
+
for (const ref of defs[id].refs ?? [])
|
|
109
|
+
visit(ref, [...path, id]);
|
|
110
|
+
state.set(id, 'done');
|
|
111
|
+
};
|
|
112
|
+
for (const id of Object.keys(defs))
|
|
113
|
+
visit(id, []);
|
|
114
|
+
return cycles;
|
|
115
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prongsjs/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The Prongs intent spec: typed intent registries, profiles, and the engine that checks how intents are used.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"intents",
|
|
7
|
+
"registry",
|
|
8
|
+
"ai-agents",
|
|
9
|
+
"design-system",
|
|
10
|
+
"typescript"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/drewpoling2/prongs#readme",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/drewpoling2/prongs.git",
|
|
16
|
+
"directory": "packages/core"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/drewpoling2/prongs/issues"
|
|
20
|
+
},
|
|
21
|
+
"author": "Drew Poling",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./engine": {
|
|
32
|
+
"types": "./dist/engine/index.d.ts",
|
|
33
|
+
"import": "./dist/engine/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"sideEffects": false,
|
|
37
|
+
"files": [
|
|
38
|
+
"dist"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
42
|
+
"test": "vitest run && tsc -p tsconfig.typetest.json",
|
|
43
|
+
"prepublishOnly": "npm run build"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"typescript": "^5.6.0",
|
|
47
|
+
"vitest": "^2.1.0"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|