@grimoire-rs/indexer 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -2
- package/dist/ci.d.ts +6 -1
- package/dist/ci.d.ts.map +1 -1
- package/dist/ci.js +32 -1
- package/dist/ci.js.map +1 -1
- package/dist/cli/build.d.ts.map +1 -1
- package/dist/cli/build.js +3 -1
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/ci.d.ts.map +1 -1
- package/dist/cli/ci.js +5 -1
- package/dist/cli/ci.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +17 -3
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +8 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/ratings.d.ts +3 -0
- package/dist/cli/ratings.d.ts.map +1 -0
- package/dist/cli/ratings.js +160 -0
- package/dist/cli/ratings.js.map +1 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +47 -1
- package/dist/config.js.map +1 -1
- package/dist/enrich/index.d.ts.map +1 -1
- package/dist/enrich/index.js +74 -1
- package/dist/enrich/index.js.map +1 -1
- package/dist/ratings/budget.d.ts +16 -0
- package/dist/ratings/budget.d.ts.map +1 -0
- package/dist/ratings/budget.js +7 -0
- package/dist/ratings/budget.js.map +1 -0
- package/dist/ratings/config.d.ts +56 -0
- package/dist/ratings/config.d.ts.map +1 -0
- package/dist/ratings/config.js +86 -0
- package/dist/ratings/config.js.map +1 -0
- package/dist/ratings/marker.d.ts +105 -0
- package/dist/ratings/marker.d.ts.map +1 -0
- package/dist/ratings/marker.js +141 -0
- package/dist/ratings/marker.js.map +1 -0
- package/dist/ratings/provider.d.ts +108 -0
- package/dist/ratings/provider.d.ts.map +1 -0
- package/dist/ratings/provider.js +202 -0
- package/dist/ratings/provider.js.map +1 -0
- package/dist/ratings/provider_github.d.ts +3 -0
- package/dist/ratings/provider_github.d.ts.map +1 -0
- package/dist/ratings/provider_github.js +134 -0
- package/dist/ratings/provider_github.js.map +1 -0
- package/dist/ratings/provider_gitlab.d.ts +3 -0
- package/dist/ratings/provider_gitlab.d.ts.map +1 -0
- package/dist/ratings/provider_gitlab.js +152 -0
- package/dist/ratings/provider_gitlab.js.map +1 -0
- package/dist/ratings/provider_memory.d.ts +28 -0
- package/dist/ratings/provider_memory.d.ts.map +1 -0
- package/dist/ratings/provider_memory.js +49 -0
- package/dist/ratings/provider_memory.js.map +1 -0
- package/dist/ratings/reconcile.d.ts +47 -0
- package/dist/ratings/reconcile.d.ts.map +1 -0
- package/dist/ratings/reconcile.js +133 -0
- package/dist/ratings/reconcile.js.map +1 -0
- package/dist/ratings/seed.d.ts +60 -0
- package/dist/ratings/seed.d.ts.map +1 -0
- package/dist/ratings/seed.js +132 -0
- package/dist/ratings/seed.js.map +1 -0
- package/dist/renderer/astro/components/Catalog.d.ts +2 -0
- package/dist/renderer/astro/components/Catalog.js +64 -15
- package/dist/renderer/astro/components/Catalog.tsx +104 -17
- package/dist/renderer/astro/layouts/Base.astro +7 -0
- package/dist/renderer/astro/lib/catalog.d.ts +25 -0
- package/dist/renderer/astro/lib/catalog.js +46 -0
- package/dist/renderer/astro/lib/catalog.ts +49 -0
- package/dist/renderer/astro/pages/p/[...slug].astro +146 -3
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +101 -2
- package/dist/renderer/index.js.map +1 -1
- package/dist/renderer/types.d.ts +41 -0
- package/dist/renderer/types.d.ts.map +1 -1
- package/dist/validate/adapters/http.d.ts +6 -1
- package/dist/validate/adapters/http.d.ts.map +1 -1
- package/dist/validate/adapters/http.js +5 -1
- package/dist/validate/adapters/http.js.map +1 -1
- package/package.json +1 -1
- package/templates/ci/github-pages.yml +11 -5
- package/templates/ci/github-ratings-seed.yml +88 -0
- package/templates/ci/github-ratings.yml +51 -0
- package/templates/ci/gitlab-ci.yml +3 -3
- package/templates/ci/gitlab-ratings-seed.sh +83 -0
- package/templates/ci/gitlab-ratings.yml +37 -0
- package/templates/gitignore +8 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 The Grimoire Authors
|
|
3
|
+
/**
|
|
4
|
+
* GitHub Discussions — GraphQL only, no REST equivalent.
|
|
5
|
+
*
|
|
6
|
+
* Upvotes are first-class here: `upvoteCount` is a scalar on the discussion, so
|
|
7
|
+
* the tally costs ~1 GraphQL point per page. Counting `reactionGroups` nodes
|
|
8
|
+
* instead would multiply nested-node cost for the same number.
|
|
9
|
+
*/
|
|
10
|
+
import { authorizeThread } from "./marker.js";
|
|
11
|
+
import { ForgeError, PAGE_SIZE, at, graphql, nodes, threadBody, stringAt, } from "./provider.js";
|
|
12
|
+
import { EXIT } from "../cli/exit.js";
|
|
13
|
+
const CATEGORIES = `
|
|
14
|
+
query($owner:String!,$name:String!){
|
|
15
|
+
repository(owner:$owner,name:$name){
|
|
16
|
+
id
|
|
17
|
+
discussionCategories(first:100){nodes{id name}}
|
|
18
|
+
}
|
|
19
|
+
}`;
|
|
20
|
+
// `databaseId` rather than the node `id`: R-1 clause 2 compares against
|
|
21
|
+
// `index-policy.json`'s `trustedBots[].id`, which is the numeric account id the
|
|
22
|
+
// validate gate already writes there — the opaque node id would never match.
|
|
23
|
+
const DISCUSSIONS = `
|
|
24
|
+
query($owner:String!,$name:String!,$categoryId:ID!,$cursor:String){
|
|
25
|
+
repository(owner:$owner,name:$name){
|
|
26
|
+
discussions(first:${PAGE_SIZE},after:$cursor,categoryId:$categoryId){
|
|
27
|
+
pageInfo{hasNextPage endCursor}
|
|
28
|
+
nodes{
|
|
29
|
+
id url upvoteCount body
|
|
30
|
+
category{id}
|
|
31
|
+
repository{id}
|
|
32
|
+
author{...on User{databaseId} ...on Bot{databaseId}}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}`;
|
|
37
|
+
const CREATE = `
|
|
38
|
+
mutation($repositoryId:ID!,$categoryId:ID!,$title:String!,$body:String!){
|
|
39
|
+
createDiscussion(input:{repositoryId:$repositoryId,categoryId:$categoryId,title:$title,body:$body}){
|
|
40
|
+
discussion{id url upvoteCount}
|
|
41
|
+
}
|
|
42
|
+
}`;
|
|
43
|
+
const LOCK = `
|
|
44
|
+
mutation($lockableId:ID!){
|
|
45
|
+
lockLockable(input:{lockableId:$lockableId}){clientMutationId}
|
|
46
|
+
}`;
|
|
47
|
+
export function githubProvider(config) {
|
|
48
|
+
const headers = { Authorization: `Bearer ${config.token}` };
|
|
49
|
+
const [owner = "", name = ""] = config.project.split("/", 2);
|
|
50
|
+
let resolved = null;
|
|
51
|
+
/** `container` is a name the operator typed; R-1 compares ids, so resolve it once. */
|
|
52
|
+
async function resolve() {
|
|
53
|
+
if (resolved)
|
|
54
|
+
return resolved;
|
|
55
|
+
const data = await graphql(config.api, headers, CATEGORIES, { owner, name });
|
|
56
|
+
const repositoryId = stringAt(data, "repository", "id");
|
|
57
|
+
if (repositoryId === "") {
|
|
58
|
+
throw new ForgeError(`${config.project}: no such repository, or it is not visible to this token`);
|
|
59
|
+
}
|
|
60
|
+
const wanted = config.container.trim().toLowerCase();
|
|
61
|
+
const categories = nodes(at(data, "repository", "discussionCategories"));
|
|
62
|
+
const match = categories.find((node) => stringAt(node, "name").trim().toLowerCase() === wanted);
|
|
63
|
+
if (!match) {
|
|
64
|
+
throw new ForgeError(`${config.project}: no discussion category named ${JSON.stringify(config.container)} ` +
|
|
65
|
+
`(found: ${categories.map((node) => stringAt(node, "name")).join(", ") || "none"})`,
|
|
66
|
+
// The operator's `container` is wrong, not the forge — a data error, so
|
|
67
|
+
// a retry on the next schedule cannot fix it and should not pretend to.
|
|
68
|
+
EXIT.data);
|
|
69
|
+
}
|
|
70
|
+
resolved = { repositoryId, categoryId: stringAt(match, "id") };
|
|
71
|
+
return resolved;
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
async listAuthored() {
|
|
75
|
+
const { repositoryId, categoryId } = await resolve();
|
|
76
|
+
const threads = [];
|
|
77
|
+
let cursor = null;
|
|
78
|
+
for (;;) {
|
|
79
|
+
const data = await graphql(config.api, headers, DISCUSSIONS, { owner, name, categoryId, cursor }, threads);
|
|
80
|
+
const page = at(data, "repository", "discussions");
|
|
81
|
+
for (const node of nodes(page)) {
|
|
82
|
+
const author = at(node, "author");
|
|
83
|
+
const databaseId = at(author, "databaseId");
|
|
84
|
+
const observed = {
|
|
85
|
+
target: stringAt(node, "id"),
|
|
86
|
+
url: stringAt(node, "url"),
|
|
87
|
+
up: typeof at(node, "upvoteCount") === "number" ? at(node, "upvoteCount") : 0,
|
|
88
|
+
body: stringAt(node, "body"),
|
|
89
|
+
authorId: typeof databaseId === "number" ? String(databaseId) : undefined,
|
|
90
|
+
containerId: stringAt(node, "repository", "id"),
|
|
91
|
+
categoryId: stringAt(node, "category", "id"),
|
|
92
|
+
};
|
|
93
|
+
const authorized = authorizeThread(observed, {
|
|
94
|
+
trustedBots: config.trustedBots,
|
|
95
|
+
containerId: repositoryId,
|
|
96
|
+
categoryId,
|
|
97
|
+
});
|
|
98
|
+
if (authorized)
|
|
99
|
+
threads.push(authorized);
|
|
100
|
+
}
|
|
101
|
+
if (at(page, "pageInfo", "hasNextPage") !== true)
|
|
102
|
+
return threads;
|
|
103
|
+
cursor = stringAt(page, "pageInfo", "endCursor");
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
async create(ref) {
|
|
107
|
+
const { repositoryId, categoryId } = await resolve();
|
|
108
|
+
const data = await graphql(config.api, headers, CREATE, {
|
|
109
|
+
repositoryId,
|
|
110
|
+
categoryId,
|
|
111
|
+
title: ref,
|
|
112
|
+
body: threadBody(ref),
|
|
113
|
+
});
|
|
114
|
+
const discussion = at(data, "createDiscussion", "discussion");
|
|
115
|
+
const target = stringAt(discussion, "id");
|
|
116
|
+
if (target === "")
|
|
117
|
+
throw new ForgeError(`${config.project}: createDiscussion returned no discussion`);
|
|
118
|
+
// Locking is the promise `lockThreads: true` makes — votes count, replies
|
|
119
|
+
// are refused — so a failure here fails the run rather than silently
|
|
120
|
+
// leaving an open forum the operator did not ask to moderate. Turn it off
|
|
121
|
+
// in `index.config.json` if the forge cannot honour it.
|
|
122
|
+
if (config.lockThreads) {
|
|
123
|
+
await graphql(config.api, headers, LOCK, { lockableId: target });
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
ref,
|
|
127
|
+
target,
|
|
128
|
+
url: stringAt(discussion, "url"),
|
|
129
|
+
up: typeof at(discussion, "upvoteCount") === "number" ? at(discussion, "upvoteCount") : 0,
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=provider_github.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider_github.js","sourceRoot":"","sources":["../../src/ratings/provider_github.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider_gitlab.d.ts","sourceRoot":"","sources":["../../src/ratings/provider_gitlab.ts"],"names":[],"mappings":"AAcA,OAAO,EAQL,KAAK,cAAc,EACnB,KAAK,oBAAoB,EAE1B,MAAM,eAAe,CAAC;AA+DvB,wBAAgB,cAAc,CAAC,MAAM,EAAE,oBAAoB,GAAG,cAAc,CA8F3E"}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 The Grimoire Authors
|
|
3
|
+
/**
|
|
4
|
+
* GitLab work items — the GraphQL surface, not the long-term-deprecated REST
|
|
5
|
+
* issues API.
|
|
6
|
+
*
|
|
7
|
+
* The counter lives on the **Emoji Reactions** widget (`upvotes`). GitLab
|
|
8
|
+
* renamed the feature from Award Emoji in 16.0 while leaving the GraphQL names
|
|
9
|
+
* (`AwardEmoji`, `awardEmojiToggle`) unchanged, so the schema below reads
|
|
10
|
+
* legacy and the prose reads current — that is the rename, not a mismatch.
|
|
11
|
+
*/
|
|
12
|
+
import { authorizeThread } from "./marker.js";
|
|
13
|
+
import { ForgeError, PAGE_SIZE, at, graphql, nodes, threadBody, stringAt, } from "./provider.js";
|
|
14
|
+
import { EXIT } from "../cli/exit.js";
|
|
15
|
+
const TYPES = `
|
|
16
|
+
query($path:ID!){
|
|
17
|
+
project(fullPath:$path){
|
|
18
|
+
id
|
|
19
|
+
workItemTypes{nodes{id name}}
|
|
20
|
+
}
|
|
21
|
+
}`;
|
|
22
|
+
const WORK_ITEMS = `
|
|
23
|
+
query($path:ID!,$types:[IssueType!],$cursor:String){
|
|
24
|
+
project(fullPath:$path){
|
|
25
|
+
id
|
|
26
|
+
workItems(first:${PAGE_SIZE},after:$cursor,types:$types){
|
|
27
|
+
pageInfo{hasNextPage endCursor}
|
|
28
|
+
nodes{
|
|
29
|
+
id webUrl
|
|
30
|
+
author{id}
|
|
31
|
+
workItemType{id}
|
|
32
|
+
widgets{
|
|
33
|
+
...on WorkItemWidgetDescription{description}
|
|
34
|
+
...on WorkItemWidgetAwardEmoji{upvotes}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}`;
|
|
40
|
+
const CREATE = `
|
|
41
|
+
mutation($path:ID!,$typeId:WorkItemsTypeID!,$title:String!,$body:String!){
|
|
42
|
+
workItemCreate(input:{namespacePath:$path,workItemTypeId:$typeId,title:$title,descriptionWidget:{description:$body}}){
|
|
43
|
+
workItem{id webUrl}
|
|
44
|
+
}
|
|
45
|
+
}`;
|
|
46
|
+
const LOCK = `
|
|
47
|
+
mutation($id:WorkItemID!){
|
|
48
|
+
workItemUpdate(input:{id:$id,notesWidget:{discussionLocked:true}}){
|
|
49
|
+
workItem{id}
|
|
50
|
+
}
|
|
51
|
+
}`;
|
|
52
|
+
/**
|
|
53
|
+
* The numeric tail of a global id — `gid://gitlab/User/42` → `42`.
|
|
54
|
+
*
|
|
55
|
+
* Only the **author** id is normalized, and only because R-1 clause 2 compares
|
|
56
|
+
* it against `index-policy.json`'s `trustedBots[].id`, which the validate gate
|
|
57
|
+
* writes as GitLab's numeric user id. Container and type ids stay whole gids:
|
|
58
|
+
* both sides of those comparisons come from this same provider, so there is
|
|
59
|
+
* nothing to agree with.
|
|
60
|
+
*/
|
|
61
|
+
function numericId(gid) {
|
|
62
|
+
const tail = gid.split("/").pop();
|
|
63
|
+
return tail !== undefined && /^\d+$/.test(tail) ? tail : undefined;
|
|
64
|
+
}
|
|
65
|
+
/** `Issue` → `ISSUE`, `Test case` → `TEST_CASE`. The `types:` filter's enum. */
|
|
66
|
+
function typeEnum(container) {
|
|
67
|
+
return container.trim().toUpperCase().replace(/\s+/g, "_");
|
|
68
|
+
}
|
|
69
|
+
export function gitlabProvider(config) {
|
|
70
|
+
const headers = { Authorization: `Bearer ${config.token}` };
|
|
71
|
+
const path = config.project;
|
|
72
|
+
let resolved = null;
|
|
73
|
+
async function resolve() {
|
|
74
|
+
if (resolved)
|
|
75
|
+
return resolved;
|
|
76
|
+
const data = await graphql(config.api, headers, TYPES, { path });
|
|
77
|
+
const projectId = stringAt(data, "project", "id");
|
|
78
|
+
if (projectId === "") {
|
|
79
|
+
throw new ForgeError(`${path}: no such project, or it is not visible to this token`);
|
|
80
|
+
}
|
|
81
|
+
const wanted = config.container.trim().toLowerCase();
|
|
82
|
+
const types = nodes(at(data, "project", "workItemTypes"));
|
|
83
|
+
const match = types.find((node) => stringAt(node, "name").trim().toLowerCase() === wanted);
|
|
84
|
+
if (!match) {
|
|
85
|
+
throw new ForgeError(`${path}: no work item type named ${JSON.stringify(config.container)} ` +
|
|
86
|
+
`(found: ${types.map((node) => stringAt(node, "name")).join(", ") || "none"})`, EXIT.data);
|
|
87
|
+
}
|
|
88
|
+
resolved = { projectId, typeId: stringAt(match, "id") };
|
|
89
|
+
return resolved;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
async listAuthored() {
|
|
93
|
+
const { projectId, typeId } = await resolve();
|
|
94
|
+
const threads = [];
|
|
95
|
+
let cursor = null;
|
|
96
|
+
for (;;) {
|
|
97
|
+
const data = await graphql(config.api, headers, WORK_ITEMS, { path, types: [typeEnum(config.container)], cursor }, threads);
|
|
98
|
+
const page = at(data, "project", "workItems");
|
|
99
|
+
for (const node of nodes(page)) {
|
|
100
|
+
const widgets = at(node, "widgets");
|
|
101
|
+
const list = Array.isArray(widgets) ? widgets : [];
|
|
102
|
+
const upvotes = list.map((w) => at(w, "upvotes")).find((v) => typeof v === "number");
|
|
103
|
+
const description = list.map((w) => at(w, "description")).find((v) => typeof v === "string");
|
|
104
|
+
const observed = {
|
|
105
|
+
target: stringAt(node, "id"),
|
|
106
|
+
url: stringAt(node, "webUrl"),
|
|
107
|
+
up: typeof upvotes === "number" ? upvotes : 0,
|
|
108
|
+
body: typeof description === "string" ? description : "",
|
|
109
|
+
authorId: numericId(stringAt(node, "author", "id")),
|
|
110
|
+
// The query is scoped to this project, so every node it returns is
|
|
111
|
+
// in it. R-1 clause 4 still compares — the rule lives in one place.
|
|
112
|
+
containerId: projectId,
|
|
113
|
+
categoryId: stringAt(node, "workItemType", "id"),
|
|
114
|
+
};
|
|
115
|
+
const authorized = authorizeThread(observed, {
|
|
116
|
+
trustedBots: config.trustedBots,
|
|
117
|
+
containerId: projectId,
|
|
118
|
+
categoryId: typeId,
|
|
119
|
+
});
|
|
120
|
+
if (authorized)
|
|
121
|
+
threads.push(authorized);
|
|
122
|
+
}
|
|
123
|
+
if (at(page, "pageInfo", "hasNextPage") !== true)
|
|
124
|
+
return threads;
|
|
125
|
+
cursor = stringAt(page, "pageInfo", "endCursor");
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
async create(ref) {
|
|
129
|
+
const { typeId } = await resolve();
|
|
130
|
+
const data = await graphql(config.api, headers, CREATE, {
|
|
131
|
+
path,
|
|
132
|
+
typeId,
|
|
133
|
+
title: ref,
|
|
134
|
+
body: threadBody(ref),
|
|
135
|
+
});
|
|
136
|
+
const item = at(data, "workItemCreate", "workItem");
|
|
137
|
+
const target = stringAt(item, "id");
|
|
138
|
+
if (target === "")
|
|
139
|
+
throw new ForgeError(`${path}: workItemCreate returned no work item`);
|
|
140
|
+
// Same contract as the GitHub side: `lockThreads: true` promises replies
|
|
141
|
+
// are refused, so failing to lock fails the run rather than quietly
|
|
142
|
+
// opening a forum the operator did not ask to moderate.
|
|
143
|
+
if (config.lockThreads) {
|
|
144
|
+
await graphql(config.api, headers, LOCK, { id: target });
|
|
145
|
+
}
|
|
146
|
+
// A fresh work item has no reactions, and asking for them would cost a
|
|
147
|
+
// second round trip to learn zero.
|
|
148
|
+
return { ref, target, url: stringAt(item, "webUrl"), up: 0 };
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=provider_gitlab.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider_gitlab.js","sourceRoot":"","sources":["../../src/ratings/provider_gitlab.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-memory `RatingProvider` — the primary local test seam.
|
|
3
|
+
*
|
|
4
|
+
* It proves the reconcile logic with no network and no fixtures: threads live
|
|
5
|
+
* in an array, `create` appends to it, and the next `listAuthored` sees them.
|
|
6
|
+
* That is what makes "a rerun is idempotent" a thing a test can assert rather
|
|
7
|
+
* than a thing a comment claims.
|
|
8
|
+
*
|
|
9
|
+
* R-1 is not re-applied here. Every thread the fake holds is one it authored,
|
|
10
|
+
* which is exactly the post-R-1 state a live provider hands back.
|
|
11
|
+
*/
|
|
12
|
+
import { type RatingProvider, type RatingThread } from "./provider.js";
|
|
13
|
+
export interface MemoryProviderOptions {
|
|
14
|
+
/** Threads already on the fake forge. Repeat a `ref` to stage a conflict. */
|
|
15
|
+
threads?: RatingThread[];
|
|
16
|
+
/** Stop the listing pass after this many threads and raise a rate limit. */
|
|
17
|
+
listLimit?: number;
|
|
18
|
+
/** Raise a rate limit on the create after this many succeeded. */
|
|
19
|
+
createLimit?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface MemoryProvider extends RatingProvider {
|
|
22
|
+
/** Live view of the fake forge — assert on it after a run. */
|
|
23
|
+
readonly threads: RatingThread[];
|
|
24
|
+
/** Targets of threads created with locking asked for. */
|
|
25
|
+
readonly locked: string[];
|
|
26
|
+
}
|
|
27
|
+
export declare function memoryProvider(options?: MemoryProviderOptions): MemoryProvider;
|
|
28
|
+
//# sourceMappingURL=provider_memory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider_memory.d.ts","sourceRoot":"","sources":["../../src/ratings/provider_memory.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;GAUG;AAEH,OAAO,EAAe,KAAK,cAAc,EAAE,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAEpF,MAAM,WAAW,qBAAqB;IACpC,6EAA6E;IAC7E,OAAO,CAAC,EAAE,YAAY,EAAE,CAAC;IACzB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,cAAe,SAAQ,cAAc;IACpD,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,EAAE,YAAY,EAAE,CAAC;IACjC,yDAAyD;IACzD,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,wBAAgB,cAAc,CAAC,OAAO,GAAE,qBAA0B,GAAG,cAAc,CAsClF"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 The Grimoire Authors
|
|
3
|
+
/**
|
|
4
|
+
* The in-memory `RatingProvider` — the primary local test seam.
|
|
5
|
+
*
|
|
6
|
+
* It proves the reconcile logic with no network and no fixtures: threads live
|
|
7
|
+
* in an array, `create` appends to it, and the next `listAuthored` sees them.
|
|
8
|
+
* That is what makes "a rerun is idempotent" a thing a test can assert rather
|
|
9
|
+
* than a thing a comment claims.
|
|
10
|
+
*
|
|
11
|
+
* R-1 is not re-applied here. Every thread the fake holds is one it authored,
|
|
12
|
+
* which is exactly the post-R-1 state a live provider hands back.
|
|
13
|
+
*/
|
|
14
|
+
import { RateLimited } from "./provider.js";
|
|
15
|
+
export function memoryProvider(options = {}) {
|
|
16
|
+
const threads = options.threads ?? [];
|
|
17
|
+
const locked = [];
|
|
18
|
+
// The fake's limits are per-run, and a run always starts by listing — so the
|
|
19
|
+
// create counter resets there rather than needing a separate reset call.
|
|
20
|
+
let created = 0;
|
|
21
|
+
return {
|
|
22
|
+
threads,
|
|
23
|
+
locked,
|
|
24
|
+
listAuthored() {
|
|
25
|
+
created = 0;
|
|
26
|
+
const { listLimit } = options;
|
|
27
|
+
if (listLimit !== undefined && listLimit < threads.length) {
|
|
28
|
+
return Promise.reject(new RateLimited("memory: secondary rate limit while listing", 60_000, threads.slice(0, listLimit)));
|
|
29
|
+
}
|
|
30
|
+
return Promise.resolve([...threads]);
|
|
31
|
+
},
|
|
32
|
+
create(ref) {
|
|
33
|
+
if (options.createLimit !== undefined && created >= options.createLimit) {
|
|
34
|
+
return Promise.reject(new RateLimited("memory: secondary rate limit while creating", 60_000));
|
|
35
|
+
}
|
|
36
|
+
created += 1;
|
|
37
|
+
const thread = {
|
|
38
|
+
ref,
|
|
39
|
+
target: `t-${ref}`,
|
|
40
|
+
url: `https://forge.example/${ref}`,
|
|
41
|
+
up: 0,
|
|
42
|
+
};
|
|
43
|
+
threads.push(thread);
|
|
44
|
+
locked.push(thread.target);
|
|
45
|
+
return Promise.resolve(thread);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=provider_memory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"provider_memory.js","sourceRoot":"","sources":["../../src/ratings/provider_memory.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,sCAAsC;AAEtC;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,WAAW,EAA0C,MAAM,eAAe,CAAC;AAkBpF,MAAM,UAAU,cAAc,CAAC,UAAiC,EAAE;IAChE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,EAAE,CAAC;IACtC,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,6EAA6E;IAC7E,yEAAyE;IACzE,IAAI,OAAO,GAAG,CAAC,CAAC;IAEhB,OAAO;QACL,OAAO;QACP,MAAM;QAEN,YAAY;YACV,OAAO,GAAG,CAAC,CAAC;YACZ,MAAM,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC;YAC9B,IAAI,SAAS,KAAK,SAAS,IAAI,SAAS,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;gBAC1D,OAAO,OAAO,CAAC,MAAM,CACnB,IAAI,WAAW,CAAC,4CAA4C,EAAE,MAAM,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,CACnG,CAAC;YACJ,CAAC;YACD,OAAO,OAAO,CAAC,OAAO,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;QACvC,CAAC;QAED,MAAM,CAAC,GAAG;YACR,IAAI,OAAO,CAAC,WAAW,KAAK,SAAS,IAAI,OAAO,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;gBACxE,OAAO,OAAO,CAAC,MAAM,CAAC,IAAI,WAAW,CAAC,6CAA6C,EAAE,MAAM,CAAC,CAAC,CAAC;YAChG,CAAC;YACD,OAAO,IAAI,CAAC,CAAC;YACb,MAAM,MAAM,GAAiB;gBAC3B,GAAG;gBACH,MAAM,EAAE,KAAK,GAAG,EAAE;gBAClB,GAAG,EAAE,yBAAyB,GAAG,EAAE;gBACnC,EAAE,EAAE,CAAC;aACN,CAAC;YACF,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YACrB,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;YAC3B,OAAO,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QACjC,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reconcile + tally loop (design §6.2). Stateless and level-triggered:
|
|
3
|
+
* every run re-derives everything, so a partial run leaves *fewer threads
|
|
4
|
+
* created*, never corrupt state, and a rerun is idempotent.
|
|
5
|
+
*
|
|
6
|
+
* Pure given a `RatingProvider` and a desired-ref set — no filesystem, no
|
|
7
|
+
* config, no network of its own.
|
|
8
|
+
*/
|
|
9
|
+
import { conflictWarning, type RefConflict } from "./marker.js";
|
|
10
|
+
import { type RatingProvider } from "./provider.js";
|
|
11
|
+
import type { StatsSeed } from "./seed.js";
|
|
12
|
+
/** One ref's `rating` value, exactly as it lands in `stats.json`. */
|
|
13
|
+
export interface RatingStat {
|
|
14
|
+
up: number;
|
|
15
|
+
target: string;
|
|
16
|
+
url: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ReconcileInput {
|
|
19
|
+
provider: RatingProvider;
|
|
20
|
+
/** Every ref in the index. */
|
|
21
|
+
desired: readonly string[];
|
|
22
|
+
/** Threads this run may create. */
|
|
23
|
+
budget: number;
|
|
24
|
+
/**
|
|
25
|
+
* The published sidecar. Read **only** to carry ratings forward when the
|
|
26
|
+
* listing pass was truncated — see the carry-forward note in [`reconcile`].
|
|
27
|
+
*/
|
|
28
|
+
seed: StatsSeed;
|
|
29
|
+
}
|
|
30
|
+
export interface ReconcileResult {
|
|
31
|
+
/** `mergeStats`'s `fresh`: ref → this producer's stat value, and nothing else. */
|
|
32
|
+
fresh: Record<string, RatingStat>;
|
|
33
|
+
refs: number;
|
|
34
|
+
created: number;
|
|
35
|
+
/** Refs with no bound thread when the run started. */
|
|
36
|
+
missing: number;
|
|
37
|
+
conflicts: RefConflict[];
|
|
38
|
+
/** A secondary limit was hit somewhere in the run. */
|
|
39
|
+
limited: boolean;
|
|
40
|
+
/** Nothing observed and nothing created, because of a rate limit. */
|
|
41
|
+
starved: boolean;
|
|
42
|
+
}
|
|
43
|
+
export { conflictWarning };
|
|
44
|
+
export declare function reconcile(input: ReconcileInput): Promise<ReconcileResult>;
|
|
45
|
+
/** The one structured line per run (design §8, Observability). */
|
|
46
|
+
export declare function logLine(result: ReconcileResult): string;
|
|
47
|
+
//# sourceMappingURL=reconcile.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../../src/ratings/reconcile.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AAEH,OAAO,EAAE,eAAe,EAAoB,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAElF,OAAO,EAAe,KAAK,cAAc,EAAqB,MAAM,eAAe,CAAC;AACpF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAE3C,qEAAqE;AACrE,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,cAAc,CAAC;IACzB,8BAA8B;IAC9B,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,mCAAmC;IACnC,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,IAAI,EAAE,SAAS,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,kFAAkF;IAClF,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,WAAW,EAAE,CAAC;IACzB,sDAAsD;IACtD,OAAO,EAAE,OAAO,CAAC;IACjB,qEAAqE;IACrE,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,OAAO,EAAE,eAAe,EAAE,CAAC;AAW3B,wBAAsB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,eAAe,CAAC,CAoG/E;AAED,kEAAkE;AAClE,wBAAgB,OAAO,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAMvD"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 The Grimoire Authors
|
|
3
|
+
/**
|
|
4
|
+
* The reconcile + tally loop (design §6.2). Stateless and level-triggered:
|
|
5
|
+
* every run re-derives everything, so a partial run leaves *fewer threads
|
|
6
|
+
* created*, never corrupt state, and a rerun is idempotent.
|
|
7
|
+
*
|
|
8
|
+
* Pure given a `RatingProvider` and a desired-ref set — no filesystem, no
|
|
9
|
+
* config, no network of its own.
|
|
10
|
+
*/
|
|
11
|
+
import { conflictWarning, resolveConflicts } from "./marker.js";
|
|
12
|
+
import { planCreations } from "./budget.js";
|
|
13
|
+
import { RateLimited } from "./provider.js";
|
|
14
|
+
export { conflictWarning };
|
|
15
|
+
/** A published `rating` value, when the seed carries one for this ref. */
|
|
16
|
+
function seededRating(seed, ref) {
|
|
17
|
+
const rating = seed.entries[ref]?.rating;
|
|
18
|
+
if (rating === null || typeof rating !== "object")
|
|
19
|
+
return null;
|
|
20
|
+
const { up, target, url } = rating;
|
|
21
|
+
if (typeof up !== "number" || typeof target !== "string" || typeof url !== "string")
|
|
22
|
+
return null;
|
|
23
|
+
return { up, target, url };
|
|
24
|
+
}
|
|
25
|
+
export async function reconcile(input) {
|
|
26
|
+
const { provider, desired, budget, seed } = input;
|
|
27
|
+
// 1. OBSERVE — paginated to exhaustion inside the provider, R-1 applied there.
|
|
28
|
+
let threads;
|
|
29
|
+
let listTruncated = false;
|
|
30
|
+
try {
|
|
31
|
+
threads = await provider.listAuthored();
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
// Every other failure propagates: a forge that did not answer says nothing
|
|
35
|
+
// about how many votes exist, and publishing on that basis is the silent
|
|
36
|
+
// emptying R-2 exists to prevent.
|
|
37
|
+
if (!(err instanceof RateLimited))
|
|
38
|
+
throw err;
|
|
39
|
+
threads = err.observed;
|
|
40
|
+
listTruncated = true;
|
|
41
|
+
}
|
|
42
|
+
let limited = listTruncated;
|
|
43
|
+
// 2. CONFLICT — a ref bound by more than one authorized thread contributes
|
|
44
|
+
// zero, so `bound` already excludes them and step 4 needs no second check.
|
|
45
|
+
const { bound, conflicts } = resolveConflicts(threads);
|
|
46
|
+
// 3. DIFF + BUDGETED CREATE — sorted, so successive runs make monotonic
|
|
47
|
+
// progress with no stored cursor. Skipped outright when the listing pass
|
|
48
|
+
// was truncated: the forge just said stop, and the diff is unreliable
|
|
49
|
+
// anyway when the observation is partial.
|
|
50
|
+
//
|
|
51
|
+
// Conflicted refs count as present, not missing. §6.2's pseudocode
|
|
52
|
+
// subtracts only `observed`, which would have this run create a *third*
|
|
53
|
+
// thread for a ref already bound by two — deepening, every run, the exact
|
|
54
|
+
// ambiguity the warning tells the operator to resolve by deleting.
|
|
55
|
+
const present = new Set([...bound.keys(), ...conflicts.map((conflict) => conflict.ref)]);
|
|
56
|
+
const plan = planCreations(desired, present, budget);
|
|
57
|
+
let created = 0;
|
|
58
|
+
if (!listTruncated) {
|
|
59
|
+
for (const ref of plan.batch) {
|
|
60
|
+
try {
|
|
61
|
+
bound.set(ref, await provider.create(ref));
|
|
62
|
+
created += 1;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (!(err instanceof RateLimited))
|
|
66
|
+
throw err;
|
|
67
|
+
limited = true;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// 4. TALLY — the forge's own scalar counter. A zero-vote thread says nothing,
|
|
73
|
+
// so it is omitted rather than published as `up: 0`.
|
|
74
|
+
const fresh = {};
|
|
75
|
+
for (const [ref, thread] of bound) {
|
|
76
|
+
if (thread.up > 0)
|
|
77
|
+
fresh[ref] = { up: thread.up, target: thread.target, url: thread.url };
|
|
78
|
+
}
|
|
79
|
+
// 4b. CARRY FORWARD what a truncated pass never reached.
|
|
80
|
+
//
|
|
81
|
+
// `mergeStats` drops the `rating` key of every ref absent from `fresh`,
|
|
82
|
+
// and that is correct *because this producer completed* — design §6.2
|
|
83
|
+
// says so in as many words, and only a FAILED producer carries forward.
|
|
84
|
+
// A truncated listing pass did not complete: refs on the pages it never
|
|
85
|
+
// read look identical to refs whose votes went to zero. Publishing on
|
|
86
|
+
// that basis is exactly the silent emptying R-2 forbids, so their
|
|
87
|
+
// published value is re-emitted instead. It is stale, not wiped, and the
|
|
88
|
+
// next complete run re-derives it.
|
|
89
|
+
//
|
|
90
|
+
// A truncated *create* pass needs none of this: listing completed, so the
|
|
91
|
+
// tally is authoritative for every thread that exists.
|
|
92
|
+
if (listTruncated) {
|
|
93
|
+
for (const ref of Object.keys(seed.entries)) {
|
|
94
|
+
if (ref in fresh)
|
|
95
|
+
continue;
|
|
96
|
+
const carried = seededRating(seed, ref);
|
|
97
|
+
if (carried)
|
|
98
|
+
fresh[ref] = carried;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
fresh,
|
|
103
|
+
refs: desired.length,
|
|
104
|
+
created,
|
|
105
|
+
missing: plan.missing.length,
|
|
106
|
+
conflicts,
|
|
107
|
+
limited,
|
|
108
|
+
// Nothing was observed and nothing was created, against an index that
|
|
109
|
+
// wanted threads — the run did no real work, and publishing on that basis
|
|
110
|
+
// empties every previously published rating at exit 0.
|
|
111
|
+
//
|
|
112
|
+
// This deliberately does NOT require `limited`. Spec review (2026-08-18,
|
|
113
|
+
// F-1) drove the real verb with a numeric `trustedBots[].id`: R-1
|
|
114
|
+
// authorized nothing, `listAuthored` returned `[]`, the run *completed*,
|
|
115
|
+
// and the merge dropped every rating key — a full wipe that R-2 never sees,
|
|
116
|
+
// because the seed fetch succeeded and the emptying arrived through the
|
|
117
|
+
// R-1 door instead. Any R-1 misconfiguration (a wrong container id, a
|
|
118
|
+
// wrong category, a bot that is not the author) has the same shape.
|
|
119
|
+
//
|
|
120
|
+
// A genuinely empty tally is distinguishable: threads whose votes all went
|
|
121
|
+
// to zero are still *observed*, so `threads.length > 0`. A first run on a
|
|
122
|
+
// fresh index creates threads, so `created > 0`. An index with no refs at
|
|
123
|
+
// all has nothing to do and is not starved.
|
|
124
|
+
starved: desired.length > 0 && threads.length === 0 && created === 0,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** The one structured line per run (design §8, Observability). */
|
|
128
|
+
export function logLine(result) {
|
|
129
|
+
return (`ratings: refs=${result.refs} created=${result.created}/${result.missing} ` +
|
|
130
|
+
`tallied=${Object.keys(result.fresh).length} conflicts=${result.conflicts.length} ` +
|
|
131
|
+
`secondary_limit_hit=${result.limited}`);
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=reconcile.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reconcile.js","sourceRoot":"","sources":["../../src/ratings/reconcile.ts"],"names":[],"mappings":"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"}
|