@pikku/core 0.12.136 → 0.12.137

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/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 0.12.137
2
+
3
+ ### Patch Changes
4
+
5
+ - 0c865d9: Changes and changesets replace milestones. The changes commands run on a local queue when the checkout has no fabric project; `pikku changes next` routes to a changes, upgrade or knowledge agent and merges finished changesets. A changeset that creates or alters a table, or has 6 or more changes, needs a plan at `knowledge/plans/<changeset>.plan.json` (anything else goes to an optional judge at `PIKKU_PLAN_JUDGE_URL`), and `changes done` holds it to that plan. `pikku knowledge gaps` replaces `knowledge next`/`reconcile`, and also reports code a merged plan built that is gone and notes that were deleted.
6
+ - c36b066: Scenario coverage moves into OSS. `@pikku/core/scenario/coverage` adds `readScenarioCoverage`, which reports the lines no scenario reaches, the mutations no scenario drives, and the pages scenarios open. It is exposed through `pikku scenario coverage`, `console:getScenarioCoverage`, and a "Not tested by any scenario" card on the Scenarios page. `LocalMetaService.getRpcMeta` now reads the `.internal.gen.json` file that codegen actually writes.
7
+ - 0963b73: Every scope tree now records where it came from: `origin` on `ScopeDefinitionMeta` is `{ kind: 'app' }` for the app's own `defineScope`, `{ kind: 'generated' }` for a tree the CLI wrote into the project (a `.gen.ts`, or the `app` root derived from personas), and `{ kind: 'addon', package, displayName }` for one an installed addon declared. An addon's build stamps its own package and display name on its trees, and the host keeps that name while recording the package it actually installed. `MetaService.getScopesMeta()` reads the result, and the console's all-meta payload carries it as `scopes`.
8
+
9
+ The console's Scopes page becomes **Permissions** and is grouped by that origin: the app's own permissions first, then what Pikku generated into the project, then one card per addon (Pikku's own before third-party, each alphabetical), each card folding to its header — the app's own starts open, every addon's starts folded, and any search or filter opens them all. Above the cards, two filters narrow the page to what one role is given (or to what no role is given yet) and to one source. A permission group no longer says "Not part of any role yet" above lines that are each given to a role.
10
+
1
11
  ## 0.12.136
2
12
 
3
13
  ### Patch Changes
@@ -10,6 +10,7 @@ import type { FeaturesMeta } from '../wirings/workflow/scenario.types.js';
10
10
  import type { ResolvedPersona } from './personas-service.js';
11
11
  import type { SystemRoleDefinitionsMeta } from '../wirings/role/role.types.js';
12
12
  import type { FeatureFlagDefinitionsMeta } from '../wirings/flag/flag.types.js';
13
+ import type { ScopeDefinitionsMeta } from '../wirings/scope/scope.types.js';
13
14
  import type { AnalyticsEventsMeta } from '../analytics/analytics.types.js';
14
15
  import type { TriggerMeta, TriggerSourceMeta } from '../wirings/trigger/trigger.types.js';
15
16
  import type { SecretDefinitionsMeta } from '../wirings/secret/secret.types.js';
@@ -156,6 +157,14 @@ export interface MetaService {
156
157
  * seed granted from rather than a second one.
157
158
  */
158
159
  getSystemRolesMeta(): Promise<SystemRoleDefinitionsMeta>;
160
+ /**
161
+ * The scope trees declared with `defineScope`, keyed by root, each carrying
162
+ * where it came from.
163
+ *
164
+ * The scope store holds the grantable ids; this holds what the store does
165
+ * not — a root's display name and which addon, if any, declared it.
166
+ */
167
+ getScopesMeta(): Promise<ScopeDefinitionsMeta>;
159
168
  /**
160
169
  * The flags declared with `defineFeatureFlags`, keyed by name.
161
170
  *
@@ -199,6 +208,7 @@ export declare class LocalMetaService implements MetaService {
199
208
  private workflowMetaCache;
200
209
  private personasMetaCache;
201
210
  private systemRolesMetaCache;
211
+ private scopesMetaCache;
202
212
  private featureFlagsMetaCache;
203
213
  private analyticsMetaCache;
204
214
  private featuresMetaCache;
@@ -236,6 +246,7 @@ export declare class LocalMetaService implements MetaService {
236
246
  getWorkflowMeta(): Promise<WorkflowsMeta>;
237
247
  getPersonasMeta(): Promise<Record<string, ResolvedPersona>>;
238
248
  getSystemRolesMeta(): Promise<SystemRoleDefinitionsMeta>;
249
+ getScopesMeta(): Promise<ScopeDefinitionsMeta>;
239
250
  getFeatureFlagsMeta(): Promise<FeatureFlagDefinitionsMeta>;
240
251
  getAnalyticsMeta(): Promise<AnalyticsEventsMeta>;
241
252
  getFeaturesMeta(): Promise<FeaturesMeta>;
@@ -16,6 +16,7 @@ export class LocalMetaService {
16
16
  workflowMetaCache = null;
17
17
  personasMetaCache = null;
18
18
  systemRolesMetaCache = null;
19
+ scopesMetaCache = null;
19
20
  featureFlagsMetaCache = null;
20
21
  analyticsMetaCache = null;
21
22
  featuresMetaCache = null;
@@ -115,6 +116,7 @@ export class LocalMetaService {
115
116
  this.workflowMetaCache = null;
116
117
  this.personasMetaCache = null;
117
118
  this.systemRolesMetaCache = null;
119
+ this.scopesMetaCache = null;
118
120
  this.featureFlagsMetaCache = null;
119
121
  this.analyticsMetaCache = null;
120
122
  this.featuresMetaCache = null;
@@ -216,7 +218,7 @@ export class LocalMetaService {
216
218
  if (this.rpcMetaCache)
217
219
  return this.rpcMetaCache;
218
220
  try {
219
- const content = await this.readFile('rpc/pikku-rpc-wirings-meta.gen.json');
221
+ const content = await this.readFile('rpc/pikku-rpc-wirings-meta.internal.gen.json');
220
222
  this.rpcMetaCache = content ? JSON.parse(content) : {};
221
223
  return this.rpcMetaCache;
222
224
  }
@@ -278,6 +280,13 @@ export class LocalMetaService {
278
280
  this.systemRolesMetaCache = content ? JSON.parse(content) : {};
279
281
  return this.systemRolesMetaCache;
280
282
  }
283
+ async getScopesMeta() {
284
+ if (this.scopesMetaCache)
285
+ return this.scopesMetaCache;
286
+ const content = await this.readFile('scopes/pikku-scopes-meta.gen.json');
287
+ this.scopesMetaCache = content ? JSON.parse(content) : {};
288
+ return this.scopesMetaCache;
289
+ }
281
290
  async getFeatureFlagsMeta() {
282
291
  if (this.featureFlagsMetaCache)
283
292
  return this.featureFlagsMetaCache;
@@ -57,5 +57,6 @@ export const buildAppScopeDefinition = (personas) => {
57
57
  displayName: 'Apps',
58
58
  description: 'Which frontend a person may sign in to',
59
59
  scopes,
60
+ origin: { kind: 'generated' },
60
61
  };
61
62
  };
@@ -1,4 +1,4 @@
1
1
  export { defineScope } from './define-scope.js';
2
2
  export { flattenScopeDefinitions, validateAndBuildScopeDefinitionsMeta, } from './validate-scope-definitions.js';
3
- export type { CoreScopes, CoreScopeNode, FlatScope, ScopeDefinitionMeta, ScopeDefinitionsMeta, ScopeDefinitions, ScopeNodeMeta, } from './scope.types.js';
3
+ export type { CoreScopes, CoreScopeNode, FlatScope, ScopeDefinitionMeta, ScopeDefinitionsMeta, ScopeDefinitions, ScopeNodeMeta, ScopeOrigin, } from './scope.types.js';
4
4
  export { hasScopes, verifyScopes } from '../../scopes.js';
@@ -21,12 +21,26 @@ export type ScopeNodeMeta = {
21
21
  description?: string;
22
22
  scopes?: Record<string, ScopeNodeMeta>;
23
23
  };
24
+ /**
25
+ * Who put a scope tree into the app: the app's own `defineScope`, a file the
26
+ * CLI generated into the project, or an installed addon.
27
+ */
28
+ export type ScopeOrigin = {
29
+ kind: 'app';
30
+ } | {
31
+ kind: 'generated';
32
+ } | {
33
+ kind: 'addon';
34
+ package: string;
35
+ displayName?: string;
36
+ };
24
37
  export type ScopeDefinitionMeta = {
25
38
  name: string;
26
39
  displayName?: string;
27
40
  description?: string;
28
41
  scopes?: Record<string, ScopeNodeMeta>;
29
42
  sourceFile?: string;
43
+ origin?: ScopeOrigin;
30
44
  };
31
45
  export type ScopeDefinitions = ScopeDefinitionMeta[];
32
46
  export type ScopeDefinitionsMeta = Record<string, ScopeDefinitionMeta>;
@@ -67,6 +67,7 @@ export function validateAndBuildScopeDefinitionsMeta(definitions) {
67
67
  description: def.description,
68
68
  scopes: def.scopes,
69
69
  sourceFile: def.sourceFile,
70
+ origin: def.origin,
70
71
  };
71
72
  }
72
73
  return meta;
@@ -0,0 +1,70 @@
1
+ import type { MetaService } from '../../services/meta-service.js';
2
+ /** Per-function line coverage as `pikku scenario run --coverage` records it. */
3
+ export interface ScenarioFunctionCoverage {
4
+ name: string;
5
+ sourceFile: string;
6
+ status: 'covered' | 'partial' | 'uncovered' | 'unknown';
7
+ totalLines: number;
8
+ missedLines: number[];
9
+ }
10
+ /** The file `pikku scenario run --coverage` writes to `.pikku/coverage/scenario-coverage.json`. */
11
+ export interface ScenarioCoverageFile {
12
+ generatedAt: string;
13
+ environment: string;
14
+ scenarios: Record<string, {
15
+ functions?: ScenarioFunctionCoverage[];
16
+ summary?: {
17
+ total?: number;
18
+ };
19
+ }>;
20
+ }
21
+ /** A function with lines no scenario reaches. */
22
+ export interface ScenarioCoverageGap {
23
+ function: string;
24
+ sourceFile: string;
25
+ status: 'uncovered' | 'partial';
26
+ /** Collapsed line ranges, e.g. `["L14-31", "L42"]`. */
27
+ missing: string[];
28
+ missedLines: number;
29
+ totalLines: number;
30
+ }
31
+ /** A mutation a user can perform that no scenario drives. */
32
+ export interface UncoveredMutation {
33
+ id: string;
34
+ sourceFile?: string;
35
+ }
36
+ export interface ScenarioCoverage {
37
+ /** Line coverage across every scenario; `null` until a run with `--coverage` has been recorded. */
38
+ api: {
39
+ generatedAt: string;
40
+ environment: string;
41
+ pct: number;
42
+ covered: number;
43
+ total: number;
44
+ gaps: ScenarioCoverageGap[];
45
+ } | null;
46
+ mutations: {
47
+ required: number;
48
+ covered: number;
49
+ uncovered: UncoveredMutation[];
50
+ };
51
+ /** Paths a scenario opens; `unvisited` only when the caller knows the app's routes. */
52
+ routes: {
53
+ visited: string[];
54
+ total: number | null;
55
+ unvisited: string[] | null;
56
+ };
57
+ }
58
+ /** Lines no scenario reaches, per function: a line counts as covered if any scenario hit it. */
59
+ export declare function aggregateScenarioCoverageGaps(file: ScenarioCoverageFile): ScenarioCoverageGap[];
60
+ /**
61
+ * Everything a scenario measures about the app, read from the meta codegen and
62
+ * `pikku scenario run --coverage` leave in `.pikku`.
63
+ *
64
+ * A mutation is a function a user changes something with: a mutating HTTP
65
+ * route, or an exposed RPC whose name opens with a mutating verb. Only RPCs a
66
+ * scenario can drive are required.
67
+ */
68
+ export declare function readScenarioCoverage(metaService: MetaService, { routes }?: {
69
+ routes?: string[];
70
+ }): Promise<ScenarioCoverage>;
@@ -0,0 +1,159 @@
1
+ const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete']);
2
+ const MUTATING_VERB = /^(create|add|new|update|edit|set|change|rename|delete|remove|destroy|archive|restore|assign|unassign|submit|approve|reject|cancel|complete|start|stop|send|invite|upload|register|book|reserve|claim|join|leave|mark|toggle|move|reorder|import|apply|save|record|log|generate|grant|revoke|link|unlink|publish|unpublish|enable|disable|accept|decline|resend|retry|sync|trigger|run|provision|deploy|purchase|checkout|pay|refund|subscribe|unsubscribe|reset|confirm|rotate|attach|detach|duplicate|clone|merge|split|schedule|reschedule|dispatch|finish|close|reopen|promote|demote|transfer|swap|seed|clear|cleanup|refresh|rebuild|install|uninstall|connect|disconnect|activate|deactivate|block|unblock|pin|unpin|vote|rate|comment|reply|post)([A-Z0-9]|$)/;
3
+ const NAVIGATION_STEPS = new Set(['opensPage']);
4
+ const isFrameworkTagged = (tags) => (tags ?? []).some((tag) => tag === 'pikku' || tag.startsWith('pikku:'));
5
+ const isGenerated = (sourceFile) => /\.gen\.ts$/.test(sourceFile ?? '');
6
+ const normalisePath = (path) => path.replace(/\/+$/, '') || '/';
7
+ const pct = (covered, total) => total === 0 ? 100 : Math.round((covered / total) * 100);
8
+ function collapseRanges(lines) {
9
+ const sorted = [...new Set(lines)].sort((a, b) => a - b);
10
+ const ranges = [];
11
+ for (let i = 0; i < sorted.length;) {
12
+ let j = i;
13
+ while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1)
14
+ j++;
15
+ ranges.push(sorted[i] === sorted[j] ? `L${sorted[i]}` : `L${sorted[i]}-${sorted[j]}`);
16
+ i = j + 1;
17
+ }
18
+ return ranges;
19
+ }
20
+ /** Lines no scenario reaches, per function: a line counts as covered if any scenario hit it. */
21
+ export function aggregateScenarioCoverageGaps(file) {
22
+ const byFunction = new Map();
23
+ for (const report of Object.values(file.scenarios ?? {})) {
24
+ for (const fn of report.functions ?? []) {
25
+ if (fn.status === 'unknown' || fn.totalLines === 0)
26
+ continue;
27
+ let entry = byFunction.get(fn.name);
28
+ if (!entry) {
29
+ entry = {
30
+ sourceFile: fn.sourceFile,
31
+ missed: null,
32
+ total: fn.totalLines,
33
+ };
34
+ byFunction.set(fn.name, entry);
35
+ }
36
+ const missed = new Set(fn.missedLines);
37
+ entry.missed =
38
+ entry.missed === null
39
+ ? missed
40
+ : new Set([...entry.missed].filter((line) => missed.has(line)));
41
+ }
42
+ }
43
+ const gaps = [];
44
+ for (const [name, entry] of byFunction) {
45
+ if (!entry.missed || entry.missed.size === 0)
46
+ continue;
47
+ const missed = [...entry.missed];
48
+ gaps.push({
49
+ function: name,
50
+ sourceFile: entry.sourceFile,
51
+ status: missed.length >= entry.total ? 'uncovered' : 'partial',
52
+ missing: collapseRanges(missed),
53
+ missedLines: missed.length,
54
+ totalLines: entry.total,
55
+ });
56
+ }
57
+ return gaps.sort((a, b) => (a.status === b.status ? 0 : a.status === 'uncovered' ? -1 : 1) ||
58
+ b.missedLines - a.missedLines ||
59
+ a.function.localeCompare(b.function));
60
+ }
61
+ function scenarioNodes(workflows) {
62
+ return Object.values(workflows).flatMap((meta) => {
63
+ const workflow = meta;
64
+ return workflow?.source === 'scenario'
65
+ ? Object.values(workflow.nodes ?? {})
66
+ : [];
67
+ });
68
+ }
69
+ /**
70
+ * Everything a scenario measures about the app, read from the meta codegen and
71
+ * `pikku scenario run --coverage` leave in `.pikku`.
72
+ *
73
+ * A mutation is a function a user changes something with: a mutating HTTP
74
+ * route, or an exposed RPC whose name opens with a mutating verb. Only RPCs a
75
+ * scenario can drive are required.
76
+ */
77
+ export async function readScenarioCoverage(metaService, { routes } = {}) {
78
+ const [functions, http, rpc, workflows, coverageFile] = await Promise.all([
79
+ metaService.getFunctionsMeta(),
80
+ metaService.getHttpMeta(),
81
+ metaService.getRpcMeta(),
82
+ metaService.getWorkflowMeta(),
83
+ metaService.readFile('coverage/scenario-coverage.json'),
84
+ ]);
85
+ const nodes = scenarioNodes(workflows);
86
+ const driven = new Set(nodes.map((node) => node?.rpcName).filter((name) => !!name));
87
+ const visited = [
88
+ ...new Set(nodes
89
+ .filter((node) => node?.rpcName && NAVIGATION_STEPS.has(node.rpcName))
90
+ .map((node) => node.input?.['path'])
91
+ .filter((path) => typeof path === 'string')
92
+ .map(normalisePath)),
93
+ ].sort();
94
+ const invocable = new Set(Object.keys(rpc));
95
+ const required = new Map();
96
+ for (const [id, meta] of Object.entries(functions)) {
97
+ const funcId = meta.pikkuFuncId ?? id;
98
+ if (meta.functionType && meta.functionType !== 'user')
99
+ continue;
100
+ if (meta.expose === false || meta.readonly === true)
101
+ continue;
102
+ if (!MUTATING_VERB.test(funcId))
103
+ continue;
104
+ if (isFrameworkTagged(meta.tags) || isGenerated(meta.sourceFile))
105
+ continue;
106
+ if (!invocable.has(funcId))
107
+ continue;
108
+ required.set(funcId, meta.sourceFile);
109
+ }
110
+ for (const [method, wirings] of Object.entries(http)) {
111
+ if (!MUTATING_METHODS.has(method.toLowerCase()))
112
+ continue;
113
+ for (const route of Object.values(wirings ?? {})) {
114
+ const funcId = route?.pikkuFuncId;
115
+ if (!funcId || !invocable.has(funcId))
116
+ continue;
117
+ if (isFrameworkTagged(route.tags))
118
+ continue;
119
+ const sourceFile = functions[funcId]?.sourceFile;
120
+ if (isGenerated(sourceFile))
121
+ continue;
122
+ required.set(funcId, sourceFile);
123
+ }
124
+ }
125
+ const uncovered = [...required]
126
+ .filter(([id]) => !driven.has(id))
127
+ .map(([id, sourceFile]) => (sourceFile ? { id, sourceFile } : { id }))
128
+ .sort((a, b) => a.id.localeCompare(b.id));
129
+ let api = null;
130
+ if (coverageFile) {
131
+ const file = JSON.parse(coverageFile);
132
+ const total = Object.values(file.scenarios ?? {}).reduce((most, one) => Math.max(most, one?.summary?.total ?? 0), 0);
133
+ if (total > 0) {
134
+ const gaps = aggregateScenarioCoverageGaps(file);
135
+ api = {
136
+ generatedAt: file.generatedAt,
137
+ environment: file.environment,
138
+ total,
139
+ covered: total - gaps.length,
140
+ pct: pct(total - gaps.length, total),
141
+ gaps,
142
+ };
143
+ }
144
+ }
145
+ const known = routes ? [...new Set(routes.map(normalisePath))].sort() : null;
146
+ return {
147
+ api,
148
+ mutations: {
149
+ required: required.size,
150
+ covered: required.size - uncovered.length,
151
+ uncovered,
152
+ },
153
+ routes: {
154
+ visited,
155
+ total: known ? known.length : null,
156
+ unvisited: known ? known.filter((path) => !visited.includes(path)) : null,
157
+ },
158
+ };
159
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.136",
3
+ "version": "0.12.137",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -39,6 +39,7 @@
39
39
  "./channel": "./dist/wirings/channel/index.js",
40
40
  "./workflow": "./dist/wirings/workflow/index.js",
41
41
  "./scenario": "./dist/wirings/workflow/pikku-scenario-service.js",
42
+ "./scenario/coverage": "./dist/wirings/workflow/scenario-coverage.js",
42
43
  "./workflow/timeline": "./dist/wirings/workflow/run-timeline.js",
43
44
  "./workflow/types": "./dist/wirings/workflow/workflow.types.js",
44
45
  "./actor-flow": "./dist/wirings/actor-flow/index.js",
@@ -99,6 +99,10 @@
99
99
  "resolveFeatureScenarios",
100
100
  "resolveScenarioSurfaces"
101
101
  ],
102
+ "./scenario/coverage": [
103
+ "aggregateScenarioCoverageGaps",
104
+ "readScenarioCoverage"
105
+ ],
102
106
  "./workflow/timeline": [
103
107
  "buildRunTimeline",
104
108
  "reconstructFinalState",