@topogram/cli 0.3.72 → 0.3.73
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 +24 -195
- package/package.json +1 -1
- package/src/adoption/plan/index.js +2 -1
- package/src/agent-brief.js +46 -2
- package/src/archive/archive.js +1 -1
- package/src/archive/jsonl.js +18 -8
- package/src/archive/resolver-bridge.js +34 -1
- package/src/archive/schema.js +1 -1
- package/src/archive/unarchive.js +26 -0
- package/src/cli/command-parsers/sdlc.js +66 -0
- package/src/cli/commands/import/help.js +1 -0
- package/src/cli/commands/import/plan.js +9 -0
- package/src/cli/commands/import/workspace.js +3 -0
- package/src/cli/commands/query/definitions.js +11 -10
- package/src/cli/commands/query/workspace.js +23 -2
- package/src/cli/commands/sdlc.js +213 -5
- package/src/cli/dispatcher.js +8 -0
- package/src/cli/help.js +14 -2
- package/src/cli/options.js +1 -0
- package/src/generator/context/shared/domain-sdlc.js +27 -0
- package/src/generator/context/shared/relationships.js +2 -1
- package/src/generator/context/shared/types.d.ts +1 -0
- package/src/generator/context/shared.d.ts +2 -0
- package/src/generator/context/shared.js +2 -0
- package/src/generator/context/slice/core.js +3 -0
- package/src/generator/context/slice/sdlc.js +57 -2
- package/src/generator/context/task-mode.js +7 -0
- package/src/generator/sdlc/board.js +2 -0
- package/src/generator/sdlc/traceability-matrix.js +5 -1
- package/src/import/core/context.js +1 -1
- package/src/import/core/contracts.js +3 -3
- package/src/import/core/registry.js +3 -0
- package/src/import/core/runner/candidates.js +7 -0
- package/src/import/core/runner/reports.js +9 -1
- package/src/import/core/runner/tracks.js +3 -0
- package/src/import/extractors/cli/generic.js +340 -0
- package/src/new-project/project-files.js +10 -3
- package/src/resolver/enrich/task.js +3 -1
- package/src/resolver/index.js +6 -0
- package/src/resolver/normalize.js +31 -0
- package/src/resolver/projections-cli.js +158 -0
- package/src/sdlc/adopt.js +4 -1
- package/src/sdlc/check.js +24 -2
- package/src/sdlc/complete.js +47 -0
- package/src/sdlc/dod/index.js +2 -0
- package/src/sdlc/dod/plan.js +15 -0
- package/src/sdlc/dod/task.js +7 -3
- package/src/sdlc/explain.js +53 -1
- package/src/sdlc/gate.js +352 -0
- package/src/sdlc/history.d.ts +7 -0
- package/src/sdlc/history.js +50 -5
- package/src/sdlc/link.js +172 -0
- package/src/sdlc/paths.d.ts +4 -0
- package/src/sdlc/paths.js +8 -0
- package/src/sdlc/plan-steps.js +71 -0
- package/src/sdlc/plan.js +245 -0
- package/src/sdlc/policy.js +249 -0
- package/src/sdlc/prep.js +186 -0
- package/src/sdlc/scaffold.js +4 -2
- package/src/sdlc/status-filter.js +2 -0
- package/src/sdlc/transitions/index.js +3 -0
- package/src/sdlc/transitions/plan.js +32 -0
- package/src/validator/common.js +25 -4
- package/src/validator/index.js +10 -0
- package/src/validator/kinds.d.ts +7 -0
- package/src/validator/kinds.js +32 -0
- package/src/validator/per-kind/plan.js +128 -0
- package/src/validator/per-kind/task.js +19 -0
- package/src/validator/projections/cli.js +267 -0
- package/src/validator.d.ts +1 -0
- package/src/workflows/import-app/shared.js +1 -1
- package/src/workflows/reconcile/adoption-plan/build.js +3 -1
- package/src/workflows/reconcile/adoption-plan/reasons.js +5 -0
- package/src/workflows/reconcile/bundle-core/index.js +3 -0
- package/src/workflows/reconcile/candidate-model.js +15 -0
- package/src/workflows/reconcile/gap-report.js +4 -2
- package/src/workflows/reconcile/impacts/adoption-plan.js +13 -0
- package/src/workflows/reconcile/renderers.js +82 -0
- package/src/workflows/reconcile/summary.js +4 -0
- package/src/workflows/reconcile/workflow.js +2 -1
- package/src/workspace-paths.js +26 -2
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
domainById,
|
|
9
9
|
getJourneyDoc,
|
|
10
10
|
pitchById,
|
|
11
|
+
planById,
|
|
11
12
|
recommendedVerificationTargets,
|
|
12
13
|
relatedEntitiesForDomain,
|
|
13
14
|
relatedProjectionsForDomain,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
summarizeDocument,
|
|
22
23
|
summarizeDomain,
|
|
23
24
|
summarizePitch,
|
|
25
|
+
summarizePlan,
|
|
24
26
|
summarizeRequirement,
|
|
25
27
|
summarizeStatementsByIds,
|
|
26
28
|
summarizeTask,
|
|
@@ -297,10 +299,12 @@ export function taskSlice(graph, taskId) {
|
|
|
297
299
|
|
|
298
300
|
const satisfies = (task.satisfies || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort();
|
|
299
301
|
const acRefs = (task.acceptanceRefs || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort();
|
|
302
|
+
const verificationRefs = (task.verificationRefs || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort();
|
|
300
303
|
const blockedBy = (task.blockedBy || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort();
|
|
301
304
|
const blocks = (task.blocks || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort();
|
|
302
305
|
const affects = (task.affects || []).map(/** @param {any} a */ (a) => (typeof a === "string" ? a : a?.id)).filter(Boolean).sort();
|
|
303
|
-
const
|
|
306
|
+
const plans = (task.plans || []).slice().sort();
|
|
307
|
+
const verifications = [...new Set([...verificationRefs, ...verificationIdsForTarget(graph, [taskId, ...affects, ...acRefs])])].sort();
|
|
304
308
|
|
|
305
309
|
return {
|
|
306
310
|
type: "context_slice",
|
|
@@ -310,15 +314,19 @@ export function taskSlice(graph, taskId) {
|
|
|
310
314
|
depends_on: {
|
|
311
315
|
satisfies,
|
|
312
316
|
acceptance_refs: acRefs,
|
|
317
|
+
verification_refs: verificationRefs,
|
|
313
318
|
blocked_by: blockedBy,
|
|
314
319
|
blocks,
|
|
320
|
+
plans,
|
|
315
321
|
affects,
|
|
316
322
|
verifications
|
|
317
323
|
},
|
|
318
324
|
related: {
|
|
319
325
|
satisfies: summarizeStatementsByIds(graph, satisfies),
|
|
320
326
|
acceptance_refs: summarizeStatementsByIds(graph, acRefs),
|
|
327
|
+
verification_refs: summarizeStatementsByIds(graph, verificationRefs),
|
|
321
328
|
blocked_by: summarizeStatementsByIds(graph, blockedBy),
|
|
329
|
+
plans: summarizeStatementsByIds(graph, plans),
|
|
322
330
|
affects: summarizeStatementsByIds(graph, affects)
|
|
323
331
|
},
|
|
324
332
|
verification: summarizeStatementsByIds(graph, verifications),
|
|
@@ -331,6 +339,54 @@ export function taskSlice(graph, taskId) {
|
|
|
331
339
|
};
|
|
332
340
|
}
|
|
333
341
|
|
|
342
|
+
/**
|
|
343
|
+
* @param {import("../shared/types.d.ts").ContextGraph} graph
|
|
344
|
+
* @param {string} planId
|
|
345
|
+
* @returns {any}
|
|
346
|
+
*/
|
|
347
|
+
export function planSlice(graph, planId) {
|
|
348
|
+
const plan = planById(graph, planId);
|
|
349
|
+
if (!plan) throw new Error(`No plan found with id '${planId}'`);
|
|
350
|
+
|
|
351
|
+
const taskId = plan.task?.id || null;
|
|
352
|
+
const task = taskId ? taskById(graph, taskId) : null;
|
|
353
|
+
const satisfies = task ? (task.satisfies || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort() : [];
|
|
354
|
+
const acRefs = task ? (task.acceptanceRefs || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort() : [];
|
|
355
|
+
const verificationRefs = task ? (task.verificationRefs || []).map(/** @param {any} r */ (r) => (typeof r === "string" ? r : r?.id)).filter(Boolean).sort() : [];
|
|
356
|
+
const affects = task ? (task.affects || []).map(/** @param {any} a */ (a) => (typeof a === "string" ? a : a?.id)).filter(Boolean).sort() : [];
|
|
357
|
+
const verifications = [...new Set([...verificationRefs, ...verificationIdsForTarget(graph, [planId, ...(taskId ? [taskId] : []), ...affects, ...acRefs])])].sort();
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
type: "context_slice",
|
|
361
|
+
version: 1,
|
|
362
|
+
focus: { kind: "plan", id: planId },
|
|
363
|
+
summary: summarizePlan(plan),
|
|
364
|
+
depends_on: {
|
|
365
|
+
task: taskId,
|
|
366
|
+
satisfies,
|
|
367
|
+
acceptance_refs: acRefs,
|
|
368
|
+
verification_refs: verificationRefs,
|
|
369
|
+
affects,
|
|
370
|
+
verifications
|
|
371
|
+
},
|
|
372
|
+
related: {
|
|
373
|
+
task: task ? [summarizeTask(task)] : [],
|
|
374
|
+
satisfies: summarizeStatementsByIds(graph, satisfies),
|
|
375
|
+
acceptance_refs: summarizeStatementsByIds(graph, acRefs),
|
|
376
|
+
verification_refs: summarizeStatementsByIds(graph, verificationRefs),
|
|
377
|
+
affects: summarizeStatementsByIds(graph, affects)
|
|
378
|
+
},
|
|
379
|
+
steps: plan.steps || [],
|
|
380
|
+
verification: summarizeStatementsByIds(graph, verifications),
|
|
381
|
+
verification_targets: recommendedVerificationTargets(graph, [planId, ...(taskId ? [taskId] : []), ...affects, ...acRefs], {
|
|
382
|
+
rationale: "Plan slice points at verification for the owning task and affected surfaces."
|
|
383
|
+
}),
|
|
384
|
+
write_scope: buildDefaultWriteScope(),
|
|
385
|
+
review_boundary: reviewBoundaryForTask(),
|
|
386
|
+
ownership_boundary: defaultOwnershipBoundary()
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
334
390
|
/**
|
|
335
391
|
* @param {import("../shared/types.d.ts").ContextGraph} graph
|
|
336
392
|
* @param {string} bugId
|
|
@@ -414,4 +470,3 @@ export function documentSlice(graph, documentId) {
|
|
|
414
470
|
ownership_boundary: defaultOwnershipBoundary()
|
|
415
471
|
};
|
|
416
472
|
}
|
|
417
|
-
|
|
@@ -234,6 +234,13 @@ function selectedSurface(options = {}) {
|
|
|
234
234
|
if (options.entityId) return { kind: "entity", id: options.entityId };
|
|
235
235
|
if (options.journeyId) return { kind: "journey", id: options.journeyId };
|
|
236
236
|
if (options.surfaceId) return { kind: "surface", id: options.surfaceId };
|
|
237
|
+
if (options.pitchId) return { kind: "pitch", id: options.pitchId };
|
|
238
|
+
if (options.requirementId) return { kind: "requirement", id: options.requirementId };
|
|
239
|
+
if (options.acceptanceId) return { kind: "acceptance_criterion", id: options.acceptanceId };
|
|
240
|
+
if (options.taskId) return { kind: "task", id: options.taskId };
|
|
241
|
+
if (options.planId) return { kind: "plan", id: options.planId };
|
|
242
|
+
if (options.bugId) return { kind: "bug", id: options.bugId };
|
|
243
|
+
if (options.documentId) return { kind: "document", id: options.documentId };
|
|
237
244
|
return null;
|
|
238
245
|
}
|
|
239
246
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
summarizeBug,
|
|
10
|
+
summarizePlan,
|
|
10
11
|
summarizePitch,
|
|
11
12
|
summarizeRequirement,
|
|
12
13
|
summarizeTask
|
|
@@ -20,6 +21,7 @@ const SUMMARIZERS = {
|
|
|
20
21
|
pitch: summarizePitch,
|
|
21
22
|
requirement: summarizeRequirement,
|
|
22
23
|
task: summarizeTask,
|
|
24
|
+
plan: summarizePlan,
|
|
23
25
|
bug: summarizeBug
|
|
24
26
|
};
|
|
25
27
|
|
|
@@ -19,7 +19,11 @@ export function generateSdlcTraceabilityMatrix(graph, options = {}) {
|
|
|
19
19
|
const pitch = pitchId ? pitchesById.get(pitchId) : null;
|
|
20
20
|
|
|
21
21
|
const taskIds = (ac.tasks || []).slice().sort();
|
|
22
|
-
const
|
|
22
|
+
const taskVerificationIds = taskIds.flatMap((id) => {
|
|
23
|
+
const task = tasksById.get(id);
|
|
24
|
+
return (task?.verificationRefs || []).map((ref) => typeof ref === "string" ? ref : ref?.id).filter(Boolean);
|
|
25
|
+
});
|
|
26
|
+
const verificationIds = [...new Set([...(ac.verifications || []), ...taskVerificationIds])].sort();
|
|
23
27
|
|
|
24
28
|
const linkedBugIds = [];
|
|
25
29
|
for (const bug of bugsById.values()) {
|
|
@@ -20,7 +20,7 @@ export function findNearestGitRoot(startDir) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function normalizeWorkspacePaths(inputPath) {
|
|
23
|
-
const context = resolveWorkspaceContext(inputPath);
|
|
23
|
+
const context = resolveWorkspaceContext(inputPath, { ignoreAncestorConfig: true });
|
|
24
24
|
const absolute = path.resolve(inputPath);
|
|
25
25
|
const topogramRoot = context.topoRoot;
|
|
26
26
|
const workspaceRoot = context.projectRoot;
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
export const IMPORT_TRACKS = new Set(["db", "api", "ui", "workflows", "verification"]);
|
|
1
|
+
export const IMPORT_TRACKS = new Set(["db", "api", "ui", "cli", "workflows", "verification"]);
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @typedef {{score:number, reasons:string[]}} DetectionResult
|
|
5
5
|
* @typedef {{findings:any[], candidates:any}} ExtractResult
|
|
6
6
|
* @typedef {{
|
|
7
7
|
* id:string,
|
|
8
|
-
* track:"db"|"api"|"ui"|"workflows"|"verification",
|
|
8
|
+
* track:"db"|"api"|"ui"|"cli"|"workflows"|"verification",
|
|
9
9
|
* detect:(context:any)=>DetectionResult,
|
|
10
10
|
* extract:(context:any)=>ExtractResult
|
|
11
11
|
* }} ImportExtractor
|
|
12
12
|
* @typedef {{
|
|
13
13
|
* id:string,
|
|
14
|
-
* track:"db"|"api"|"ui"|"workflows"|"verification"|"docs",
|
|
14
|
+
* track:"db"|"api"|"ui"|"cli"|"workflows"|"verification"|"docs",
|
|
15
15
|
* applies:(context:any, candidates:any)=>boolean|number,
|
|
16
16
|
* enrich:(context:any, candidates:any)=>any
|
|
17
17
|
* }} ImportEnricher
|
|
@@ -47,6 +47,7 @@ import { reactNativeScreensExtractor } from "../extractors/ui/react-native-scree
|
|
|
47
47
|
import { reactRouterUiExtractor } from "../extractors/ui/react-router.js";
|
|
48
48
|
import { svelteKitUiExtractor } from "../extractors/ui/sveltekit.js";
|
|
49
49
|
import { backendOnlyUiExtractor } from "../extractors/ui/backend-only.js";
|
|
50
|
+
import { genericCliExtractor } from "../extractors/cli/generic.js";
|
|
50
51
|
import { genericWorkflowExtractor } from "../extractors/workflows/generic.js";
|
|
51
52
|
import { genericVerificationExtractor } from "../extractors/verification/generic.js";
|
|
52
53
|
import { authSessionEnricher } from "../enrichers/auth-session.js";
|
|
@@ -60,6 +61,7 @@ export const extractorRegistry = {
|
|
|
60
61
|
db: [prismaExtractor, djangoModelsExtractor, efCoreExtractor, roomExtractor, swiftDataExtractor, dotnetModelsExtractor, flutterEntitiesExtractor, reactNativeEntitiesExtractor, railsSchemaExtractor, liquibaseExtractor, myBatisXmlExtractor, jpaExtractor, drizzleExtractor, sqlExtractor, snapshotExtractor],
|
|
61
62
|
api: [openApiExtractor, openApiCodeExtractor, graphQlSdlExtractor, graphQlCodeFirstExtractor, trpcExtractor, aspNetCoreExtractor, retrofitExtractor, swiftWebApiExtractor, flutterDioExtractor, reactNativeRepositoryExtractor, fastifyExtractor, expressExtractor, djangoRoutesExtractor, railsRoutesExtractor, micronautExtractor, jaxRsExtractor, springWebExtractor, nextRouteExtractor, genericRouteFallbackExtractor, nextServerActionExtractor, nextAuthExtractor],
|
|
62
63
|
ui: [nextAppRouterUiExtractor, nextPagesRouterUiExtractor, androidComposeUiExtractor, blazorUiExtractor, razorPagesUiExtractor, swiftUiExtractor, uiKitExtractor, mauiXamlUiExtractor, flutterScreensUiExtractor, reactNativeScreensExtractor, reactRouterUiExtractor, svelteKitUiExtractor, backendOnlyUiExtractor],
|
|
64
|
+
cli: [genericCliExtractor],
|
|
63
65
|
workflows: [genericWorkflowExtractor],
|
|
64
66
|
verification: [genericVerificationExtractor]
|
|
65
67
|
};
|
|
@@ -68,6 +70,7 @@ export const enricherRegistry = {
|
|
|
68
70
|
db: [railsModelEnricher],
|
|
69
71
|
api: [djangoRestEnricher, railsControllerEnricher, authSessionEnricher],
|
|
70
72
|
ui: [],
|
|
73
|
+
cli: [],
|
|
71
74
|
workflows: [workflowTargetStateEnricher, docLinkingEnricher],
|
|
72
75
|
verification: []
|
|
73
76
|
};
|
|
@@ -288,6 +288,13 @@ export function normalizeCandidatesForTrack(track, candidates) {
|
|
|
288
288
|
stacks: [...new Set(candidates.stacks || [])].sort()
|
|
289
289
|
};
|
|
290
290
|
}
|
|
291
|
+
if (track === "cli") {
|
|
292
|
+
return {
|
|
293
|
+
commands: dedupeCandidateRecords(candidates.commands || [], (/** @type {any} */ record) => record.command_id || record.id_hint),
|
|
294
|
+
capabilities: dedupeCandidateRecords(candidates.capabilities || [], idHint),
|
|
295
|
+
surfaces: dedupeCandidateRecords(candidates.surfaces || [], idHint)
|
|
296
|
+
};
|
|
297
|
+
}
|
|
291
298
|
if (track === "verification") {
|
|
292
299
|
return {
|
|
293
300
|
verifications: dedupeCandidateRecords(candidates.verifications || [], idHint),
|
|
@@ -29,6 +29,14 @@ export function reportMarkdown(track, candidates) {
|
|
|
29
29
|
`# UI Import Report\n\n- Screens: ${candidates.screens.length}\n- Routes: ${candidates.routes.length}\n- Actions: ${candidates.actions.length}\n- Widgets: ${widgets.length}\n- Event payload shapes: ${shapes.length}\n- Stacks: ${candidates.stacks.length ? candidates.stacks.join(", ") : "none"}\n\n## Widget Candidates\n\n${widgetLines.length ? widgetLines.join("\n") : "- none"}\n\n## Next Validation\n\n- Review candidates under \`topo/candidates/app/ui/drafts/widgets/**\`.\n- Run \`topogram import plan <path>\` before adoption.\n- After adoption, run \`topogram check <path>\`, \`topogram widget check <path>\`, and \`topogram widget behavior <path>\`.\n`
|
|
30
30
|
);
|
|
31
31
|
}
|
|
32
|
+
if (track === "cli") {
|
|
33
|
+
const commandLines = (candidates.commands || []).map((/** @type {any} */ command) =>
|
|
34
|
+
`- \`${command.command_id || command.id_hint}\` usage \`${command.usage || "unknown"}\` effects ${(command.effects || []).map((/** @type {any} */ effect) => `\`${effect}\``).join(", ") || "_none_"}`
|
|
35
|
+
);
|
|
36
|
+
return ensureTrailingNewline(
|
|
37
|
+
`# CLI Import Report\n\n- Commands: ${candidates.commands.length}\n- Capabilities: ${candidates.capabilities.length}\n- CLI surfaces: ${candidates.surfaces.length}\n\n## Command Candidates\n\n${commandLines.length ? commandLines.join("\n") : "- none"}\n`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
32
40
|
if (track === "verification") {
|
|
33
41
|
return ensureTrailingNewline(
|
|
34
42
|
`# Verification Import Report\n\n- Verifications: ${candidates.verifications.length}\n- Scenarios: ${candidates.scenarios.length}\n- Frameworks: ${candidates.frameworks.length ? candidates.frameworks.join(", ") : "none"}\n- Scripts: ${candidates.scripts.length}\n`
|
|
@@ -46,6 +54,6 @@ export function reportMarkdown(track, candidates) {
|
|
|
46
54
|
*/
|
|
47
55
|
export function appReportMarkdown(candidates, tracks) {
|
|
48
56
|
return ensureTrailingNewline(
|
|
49
|
-
`# App Import Report\n\nTracks: ${tracks.join(", ")}\n\n## DB\n\n- Entities: ${candidates.db?.entities?.length || 0}\n- Enums: ${candidates.db?.enums?.length || 0}\n- Relations: ${candidates.db?.relations?.length || 0}\n\n## API\n\n- Capabilities: ${candidates.api?.capabilities?.length || 0}\n- Routes: ${candidates.api?.routes?.length || 0}\n- Stacks: ${candidates.api?.stacks?.length ? candidates.api.stacks.join(", ") : "none"}\n\n## UI\n\n- Screens: ${candidates.ui?.screens?.length || 0}\n- Routes: ${candidates.ui?.routes?.length || 0}\n- Actions: ${candidates.ui?.actions?.length || 0}\n- Widgets: ${uiWidgetCandidates(candidates.ui).length}\n- Event payload shapes: ${candidates.ui?.shapes?.length || 0}\n- Stacks: ${candidates.ui?.stacks?.length ? candidates.ui.stacks.join(", ") : "none"}\n\n## Workflows\n\n- Workflows: ${candidates.workflows?.workflows?.length || 0}\n- States: ${candidates.workflows?.workflow_states?.length || 0}\n- Transitions: ${candidates.workflows?.workflow_transitions?.length || 0}\n\n## Verification\n\n- Verifications: ${candidates.verification?.verifications?.length || 0}\n- Scenarios: ${candidates.verification?.scenarios?.length || 0}\n- Frameworks: ${candidates.verification?.frameworks?.length ? candidates.verification.frameworks.join(", ") : "none"}\n- Scripts: ${candidates.verification?.scripts?.length || 0}\n`
|
|
57
|
+
`# App Import Report\n\nTracks: ${tracks.join(", ")}\n\n## DB\n\n- Entities: ${candidates.db?.entities?.length || 0}\n- Enums: ${candidates.db?.enums?.length || 0}\n- Relations: ${candidates.db?.relations?.length || 0}\n\n## API\n\n- Capabilities: ${candidates.api?.capabilities?.length || 0}\n- Routes: ${candidates.api?.routes?.length || 0}\n- Stacks: ${candidates.api?.stacks?.length ? candidates.api.stacks.join(", ") : "none"}\n\n## UI\n\n- Screens: ${candidates.ui?.screens?.length || 0}\n- Routes: ${candidates.ui?.routes?.length || 0}\n- Actions: ${candidates.ui?.actions?.length || 0}\n- Widgets: ${uiWidgetCandidates(candidates.ui).length}\n- Event payload shapes: ${candidates.ui?.shapes?.length || 0}\n- Stacks: ${candidates.ui?.stacks?.length ? candidates.ui.stacks.join(", ") : "none"}\n\n## CLI\n\n- Commands: ${candidates.cli?.commands?.length || 0}\n- Capabilities: ${candidates.cli?.capabilities?.length || 0}\n- CLI surfaces: ${candidates.cli?.surfaces?.length || 0}\n\n## Workflows\n\n- Workflows: ${candidates.workflows?.workflows?.length || 0}\n- States: ${candidates.workflows?.workflow_states?.length || 0}\n- Transitions: ${candidates.workflows?.workflow_transitions?.length || 0}\n\n## Verification\n\n- Verifications: ${candidates.verification?.verifications?.length || 0}\n- Scenarios: ${candidates.verification?.scenarios?.length || 0}\n- Frameworks: ${candidates.verification?.frameworks?.length ? candidates.verification.frameworks.join(", ") : "none"}\n- Scripts: ${candidates.verification?.scripts?.length || 0}\n`
|
|
50
58
|
);
|
|
51
59
|
}
|
|
@@ -104,6 +104,9 @@ function initialCandidatesForTrack(track) {
|
|
|
104
104
|
if (track === "ui") {
|
|
105
105
|
return { screens: [], routes: [], actions: [], stacks: [] };
|
|
106
106
|
}
|
|
107
|
+
if (track === "cli") {
|
|
108
|
+
return { commands: [], capabilities: [], surfaces: [] };
|
|
109
|
+
}
|
|
107
110
|
if (track === "verification") {
|
|
108
111
|
return { verifications: [], scenarios: [], frameworks: [], scripts: [] };
|
|
109
112
|
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
findImportFiles,
|
|
5
|
+
idHintify,
|
|
6
|
+
makeCandidateRecord,
|
|
7
|
+
normalizeImportRelativePath,
|
|
8
|
+
readJsonIfExists,
|
|
9
|
+
readTextIfExists,
|
|
10
|
+
titleCase
|
|
11
|
+
} from "../../core/shared.js";
|
|
12
|
+
|
|
13
|
+
const CLI_SOURCE_PATTERN = /(^|\/)(bin|cli|command|commands|parser|help)(\/|[-_.A-Za-z0-9]*\.(?:js|mjs|cjs|ts))$/i;
|
|
14
|
+
const JS_SOURCE_PATTERN = /\.(?:js|mjs|cjs|ts)$/i;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} value
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function normalizePath(value) {
|
|
21
|
+
return value.replaceAll("\\", "/");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} commandId
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function capabilityIdForCommand(commandId) {
|
|
29
|
+
return `cap_${idHintify(commandId)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} text
|
|
34
|
+
* @returns {string[]}
|
|
35
|
+
*/
|
|
36
|
+
function extractUsageLines(text) {
|
|
37
|
+
const lines = [];
|
|
38
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
39
|
+
const line = rawLine
|
|
40
|
+
.replace(/^\s*(?:console\.log\(|print\(|echo\s+)?["'`]*/, "")
|
|
41
|
+
.replace(/["'`),;]*\s*$/, "")
|
|
42
|
+
.trim();
|
|
43
|
+
if (!line) continue;
|
|
44
|
+
const usageMatch = line.match(/(?:^|\b)Usage:\s*(.+)$/i);
|
|
45
|
+
if (usageMatch?.[1]) {
|
|
46
|
+
lines.push(usageMatch[1].trim());
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (/^[a-zA-Z][\w.-]+(?:\s+[a-zA-Z][\w:-]+)+(?:\s|$)/.test(line) && /(?:--[a-zA-Z][\w:-]*|<[^>]+>|\[[^\]]+\])/.test(line)) {
|
|
50
|
+
lines.push(line);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return [...new Set(lines)].sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} usage
|
|
58
|
+
* @param {Set<string>} binNames
|
|
59
|
+
* @returns {{ id: string, commandPath: string[] } | null}
|
|
60
|
+
*/
|
|
61
|
+
function commandIdFromUsage(usage, binNames) {
|
|
62
|
+
const tokens = usage.split(/\s+/).filter(Boolean);
|
|
63
|
+
if (tokens.length === 0) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const firstCommandToken = binNames.has(tokens[0]) ? 1 : 0;
|
|
67
|
+
const commandPath = [];
|
|
68
|
+
for (let index = firstCommandToken; index < tokens.length; index += 1) {
|
|
69
|
+
const token = tokens[index];
|
|
70
|
+
if (!token || token.startsWith("-") || token.startsWith("[") || token.startsWith("<")) {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
commandPath.push(token.replace(/[^a-zA-Z0-9:_-]/g, ""));
|
|
74
|
+
}
|
|
75
|
+
if (commandPath.length === 0) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
id: idHintify(commandPath.join("_")),
|
|
80
|
+
commandPath
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} usage
|
|
86
|
+
* @param {string} commandId
|
|
87
|
+
* @returns {any[]}
|
|
88
|
+
*/
|
|
89
|
+
function optionsFromUsage(usage, commandId) {
|
|
90
|
+
const options = [];
|
|
91
|
+
const optionPattern = /--([a-zA-Z][\w:-]*)(?:\s+(<[^>]+>|\[[^\]]+\]))?/g;
|
|
92
|
+
for (const match of usage.matchAll(optionPattern)) {
|
|
93
|
+
const optionName = idHintify(match[1]);
|
|
94
|
+
options.push({
|
|
95
|
+
command_id: commandId,
|
|
96
|
+
name: optionName,
|
|
97
|
+
flag: `--${match[1]}`,
|
|
98
|
+
type: match[2] ? "string" : "boolean",
|
|
99
|
+
required: false,
|
|
100
|
+
values: [],
|
|
101
|
+
description: null
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return options;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {string} commandId
|
|
109
|
+
* @param {string} usage
|
|
110
|
+
* @param {string} sourceText
|
|
111
|
+
* @returns {string[]}
|
|
112
|
+
*/
|
|
113
|
+
function effectsForCommand(commandId, usage, sourceText) {
|
|
114
|
+
const text = `${commandId} ${usage}`.toLowerCase();
|
|
115
|
+
const effects = new Set();
|
|
116
|
+
if (/\b(check|list|show|help|doctor|status|brief|explain|query|emit)\b/.test(text)) {
|
|
117
|
+
effects.add("read_only");
|
|
118
|
+
}
|
|
119
|
+
if (/\b(import|adopt|new|generate|refresh|update|write|create|copy)\b/.test(text)) {
|
|
120
|
+
effects.add("writes_workspace");
|
|
121
|
+
effects.add("filesystem");
|
|
122
|
+
}
|
|
123
|
+
if (/\b(fetch|https?:\/\/|github|gh_token|github_token|network|catalog)\b/.test(text)) {
|
|
124
|
+
effects.add("network");
|
|
125
|
+
}
|
|
126
|
+
if (/\b(npm|package|install|publish|pack)\b/.test(text)) {
|
|
127
|
+
effects.add("package_install");
|
|
128
|
+
}
|
|
129
|
+
if (/\b(git|worktree|commit|push|pull|checkout|branch)\b/.test(text)) {
|
|
130
|
+
effects.add("git");
|
|
131
|
+
}
|
|
132
|
+
if (effects.size === 0) {
|
|
133
|
+
effects.add("read_only");
|
|
134
|
+
}
|
|
135
|
+
return [...effects].sort();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {string} commandId
|
|
140
|
+
* @param {any[]} options
|
|
141
|
+
* @returns {any[]}
|
|
142
|
+
*/
|
|
143
|
+
function outputsForCommand(commandId, options) {
|
|
144
|
+
const hasJson = options.some((option) => option.command_id === commandId && option.name === "json");
|
|
145
|
+
return [
|
|
146
|
+
...(hasJson ? [{ command_id: commandId, format: "json", schema_id: null, description: "Machine-readable JSON output" }] : []),
|
|
147
|
+
{ command_id: commandId, format: "human", schema_id: null, description: "Human-readable terminal output" }
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {any[]} records
|
|
153
|
+
* @param {(record: any) => string} keyFn
|
|
154
|
+
* @returns {any[]}
|
|
155
|
+
*/
|
|
156
|
+
function dedupeRecords(records, keyFn) {
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
const deduped = [];
|
|
159
|
+
for (const record of records) {
|
|
160
|
+
const key = keyFn(record);
|
|
161
|
+
if (seen.has(key)) continue;
|
|
162
|
+
seen.add(key);
|
|
163
|
+
deduped.push(record);
|
|
164
|
+
}
|
|
165
|
+
return deduped;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {any} context
|
|
170
|
+
* @returns {{ packageFiles: string[], sourceFiles: string[] }}
|
|
171
|
+
*/
|
|
172
|
+
function discoverCliSources(context) {
|
|
173
|
+
const packageFiles = findImportFiles(context.paths, (/** @type {string} */ filePath) => /package\.json$/i.test(filePath));
|
|
174
|
+
const sourceFiles = findImportFiles(context.paths, (/** @type {string} */ filePath) => {
|
|
175
|
+
const normalized = normalizePath(filePath);
|
|
176
|
+
return JS_SOURCE_PATTERN.test(normalized) && (
|
|
177
|
+
CLI_SOURCE_PATTERN.test(normalized) ||
|
|
178
|
+
normalized.includes("/src/cli/") ||
|
|
179
|
+
normalized.includes("/commands/")
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
return { packageFiles, sourceFiles };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const genericCliExtractor = {
|
|
186
|
+
id: "cli.generic",
|
|
187
|
+
track: "cli",
|
|
188
|
+
/** @param {any} context */
|
|
189
|
+
detect(context) {
|
|
190
|
+
const { packageFiles, sourceFiles } = discoverCliSources(context);
|
|
191
|
+
const hasBin = packageFiles.some((filePath) => {
|
|
192
|
+
const pkg = readJsonIfExists(filePath);
|
|
193
|
+
return Boolean(pkg?.bin);
|
|
194
|
+
});
|
|
195
|
+
const score = (hasBin ? 70 : 0) + Math.min(30, sourceFiles.length * 5);
|
|
196
|
+
return {
|
|
197
|
+
score,
|
|
198
|
+
reasons: [
|
|
199
|
+
hasBin ? "package.json declares a CLI bin" : null,
|
|
200
|
+
sourceFiles.length ? `${sourceFiles.length} CLI-like source files found` : null
|
|
201
|
+
].filter(Boolean)
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
/** @param {any} context */
|
|
205
|
+
extract(context) {
|
|
206
|
+
const { packageFiles, sourceFiles } = discoverCliSources(context);
|
|
207
|
+
const findings = [];
|
|
208
|
+
const commands = [];
|
|
209
|
+
const options = [];
|
|
210
|
+
const outputs = [];
|
|
211
|
+
const effects = [];
|
|
212
|
+
const examples = [];
|
|
213
|
+
const capabilities = [];
|
|
214
|
+
const binNames = new Set();
|
|
215
|
+
const provenance = [];
|
|
216
|
+
|
|
217
|
+
for (const packagePath of packageFiles) {
|
|
218
|
+
const pkg = readJsonIfExists(packagePath);
|
|
219
|
+
if (!pkg) continue;
|
|
220
|
+
const relPath = normalizeImportRelativePath(context.paths, packagePath);
|
|
221
|
+
const bin = pkg.bin;
|
|
222
|
+
if (typeof bin === "string") {
|
|
223
|
+
binNames.add(pkg.name ? String(pkg.name).split("/").pop() : "cli");
|
|
224
|
+
} else if (bin && typeof bin === "object") {
|
|
225
|
+
for (const name of Object.keys(bin)) {
|
|
226
|
+
binNames.add(name);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
for (const [name, command] of Object.entries(pkg.scripts || {})) {
|
|
230
|
+
if (/^(cli|bin|start|check|test|verify)(:|$)/.test(name) || /\b(node|tsx|ts-node)\b.+\b(cli|bin)\b/i.test(String(command))) {
|
|
231
|
+
findings.push({
|
|
232
|
+
kind: "cli_script",
|
|
233
|
+
name,
|
|
234
|
+
command,
|
|
235
|
+
source: relPath
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
provenance.push(`${relPath}#bin`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const sourcePath of sourceFiles) {
|
|
243
|
+
const sourceText = readTextIfExists(sourcePath) || "";
|
|
244
|
+
const relPath = normalizeImportRelativePath(context.paths, sourcePath);
|
|
245
|
+
const usageLines = extractUsageLines(sourceText);
|
|
246
|
+
if (usageLines.length === 0) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
findings.push({
|
|
250
|
+
kind: "cli_usage",
|
|
251
|
+
source: relPath,
|
|
252
|
+
usages: usageLines
|
|
253
|
+
});
|
|
254
|
+
for (const usage of usageLines) {
|
|
255
|
+
const command = commandIdFromUsage(usage, binNames);
|
|
256
|
+
if (!command) continue;
|
|
257
|
+
const commandOptions = optionsFromUsage(usage, command.id);
|
|
258
|
+
const commandEffects = effectsForCommand(command.id, usage, sourceText);
|
|
259
|
+
const capabilityId = capabilityIdForCommand(command.id);
|
|
260
|
+
const commandProvenance = [`${relPath}#${usage}`];
|
|
261
|
+
commands.push(makeCandidateRecord({
|
|
262
|
+
kind: "cli_command",
|
|
263
|
+
idHint: `cmd_${command.id}`,
|
|
264
|
+
label: titleCase(command.commandPath.join(" ")),
|
|
265
|
+
confidence: "medium",
|
|
266
|
+
sourceKind: "cli_usage",
|
|
267
|
+
sourceOfTruth: "candidate",
|
|
268
|
+
provenance: commandProvenance,
|
|
269
|
+
track: "cli",
|
|
270
|
+
command_id: command.id,
|
|
271
|
+
command_path: command.commandPath,
|
|
272
|
+
capability_id: capabilityId,
|
|
273
|
+
usage,
|
|
274
|
+
mode: commandEffects.includes("writes_workspace") ? "writes_workspace" : commandEffects[0],
|
|
275
|
+
options: commandOptions.map((option) => option.name),
|
|
276
|
+
effects: commandEffects
|
|
277
|
+
}));
|
|
278
|
+
capabilities.push(makeCandidateRecord({
|
|
279
|
+
kind: "capability",
|
|
280
|
+
idHint: capabilityId,
|
|
281
|
+
label: titleCase(command.commandPath.join(" ")),
|
|
282
|
+
confidence: "medium",
|
|
283
|
+
sourceKind: "cli_command",
|
|
284
|
+
sourceOfTruth: "candidate",
|
|
285
|
+
provenance: commandProvenance,
|
|
286
|
+
track: "cli",
|
|
287
|
+
command_id: command.id
|
|
288
|
+
}));
|
|
289
|
+
options.push(...commandOptions.map((option) => ({
|
|
290
|
+
...option,
|
|
291
|
+
provenance: commandProvenance
|
|
292
|
+
})));
|
|
293
|
+
outputs.push(...outputsForCommand(command.id, commandOptions).map((output) => ({
|
|
294
|
+
...output,
|
|
295
|
+
provenance: commandProvenance
|
|
296
|
+
})));
|
|
297
|
+
effects.push(...commandEffects.map((effect) => ({
|
|
298
|
+
command_id: command.id,
|
|
299
|
+
effect,
|
|
300
|
+
target: effect === "read_only" ? "workspace" : null,
|
|
301
|
+
provenance: commandProvenance
|
|
302
|
+
})));
|
|
303
|
+
examples.push({
|
|
304
|
+
command_id: command.id,
|
|
305
|
+
example: usage,
|
|
306
|
+
provenance: commandProvenance
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const surfaceCommands = dedupeRecords(commands, (record) => record.command_id);
|
|
312
|
+
const surfaces = surfaceCommands.length > 0
|
|
313
|
+
? [makeCandidateRecord({
|
|
314
|
+
kind: "cli_surface",
|
|
315
|
+
idHint: "proj_cli_surface",
|
|
316
|
+
label: "CLI Surface",
|
|
317
|
+
confidence: "medium",
|
|
318
|
+
sourceKind: "cli_usage",
|
|
319
|
+
sourceOfTruth: "candidate",
|
|
320
|
+
provenance,
|
|
321
|
+
track: "cli",
|
|
322
|
+
commands: surfaceCommands.map((record) => record.command_id),
|
|
323
|
+
command_records: surfaceCommands,
|
|
324
|
+
options: dedupeRecords(options, (record) => `${record.command_id}:${record.name}`),
|
|
325
|
+
outputs: dedupeRecords(outputs, (record) => `${record.command_id}:${record.format}`),
|
|
326
|
+
effects: dedupeRecords(effects, (record) => `${record.command_id}:${record.effect}`),
|
|
327
|
+
examples: dedupeRecords(examples, (record) => `${record.command_id}:${record.example}`)
|
|
328
|
+
})]
|
|
329
|
+
: [];
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
findings,
|
|
333
|
+
candidates: {
|
|
334
|
+
commands: surfaceCommands,
|
|
335
|
+
capabilities: dedupeRecords(capabilities, (record) => record.id_hint),
|
|
336
|
+
surfaces
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
};
|
|
@@ -285,9 +285,10 @@ Start here before editing this Topogram project.
|
|
|
285
285
|
1. \`AGENTS.md\`
|
|
286
286
|
2. \`README.md\`
|
|
287
287
|
3. \`topogram.project.json\`
|
|
288
|
-
4. \`topogram.
|
|
289
|
-
5. \`topogram.
|
|
290
|
-
|
|
288
|
+
4. \`topogram.sdlc-policy.json\`, if this project has adopted SDLC enforcement
|
|
289
|
+
5. \`topogram.template-policy.json\`
|
|
290
|
+
6. \`topogram.generator-policy.json\`
|
|
291
|
+
${hasImplementation ? "7. `.topogram-template-trust.json`\n8. `implementation/`\n9. Focused `topogram query ...` output\n" : "7. Focused `topogram query ...` output\n"}
|
|
291
292
|
Machine-readable source:
|
|
292
293
|
|
|
293
294
|
\`\`\`bash
|
|
@@ -310,6 +311,8 @@ npm run doctor
|
|
|
310
311
|
npm run source:status
|
|
311
312
|
npm run template:explain
|
|
312
313
|
npm run generator:policy:check
|
|
314
|
+
topogram sdlc policy explain --json
|
|
315
|
+
topogram sdlc explain <task-or-bug-id> --json
|
|
313
316
|
${hasImplementation ? "npm run trust:status\n" : ""}npm run check
|
|
314
317
|
npm run query:list
|
|
315
318
|
npm run query:show -- widget-behavior
|
|
@@ -318,6 +321,10 @@ npm run query:show -- widget-behavior
|
|
|
318
321
|
## Edit Rules
|
|
319
322
|
|
|
320
323
|
- Edit \`topo/**\` and \`topogram.project.json\` first.
|
|
324
|
+
- If \`topogram.sdlc-policy.json\` exists, protected changes need an SDLC item, a \`topo/sdlc/**\` SDLC record update, or an allowed exemption.
|
|
325
|
+
- Adopted SDLC records default to \`topo/sdlc/**\`; custom folders can still validate, but agents should look there first.
|
|
326
|
+
- Status, history, archives, trust hashes, provenance, generated sentinels, and release/rollout state are command-owned. Use Topogram commands instead of hand-editing those sidecars.
|
|
327
|
+
- Plans are optional support records. Agents may edit plan text directly, but should use \`topogram sdlc plan step ... --write\` for step status changes.
|
|
321
328
|
- Review policy files before editing \`topogram.template-policy.json\` or \`topogram.generator-policy.json\`.
|
|
322
329
|
- Do not make lasting edits under generated-owned \`app/**\`; use \`npm run generate\` to replace generated output.
|
|
323
330
|
- If an output is changed to maintained ownership, agents may edit that app code directly after reading focused query packets.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Back-link arrays:
|
|
5
5
|
// blockingMe — tasks whose `blocks` references this task (reciprocal of blocked_by)
|
|
6
6
|
// blockedByMe — tasks whose `blocked_by` references this task (reciprocal of blocks)
|
|
7
|
+
// plans — implementation plans attached to this task
|
|
7
8
|
//
|
|
8
9
|
// Note: we deliberately compute *both* directions even though `blocks` and
|
|
9
10
|
// `blocked_by` are reciprocal, because authors only write one side. The
|
|
@@ -13,6 +14,7 @@
|
|
|
13
14
|
export function enrichTask(task, index) {
|
|
14
15
|
return {
|
|
15
16
|
blockingMe: (index.tasksThatBlockTarget.get(task.id) || []).slice().sort(),
|
|
16
|
-
blockedByMe: (index.tasksBlockedByTarget.get(task.id) || []).slice().sort()
|
|
17
|
+
blockedByMe: (index.tasksBlockedByTarget.get(task.id) || []).slice().sort(),
|
|
18
|
+
plans: (index.plansByTask.get(task.id) || []).slice().sort()
|
|
17
19
|
};
|
|
18
20
|
}
|