@intentius/chant 0.124.21 → 0.124.23
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/dist/audit/rules-doc.js +1 -1
- package/dist/cli/handlers/graph.js +24 -1
- package/dist/codegen/docs-pages.d.ts +1 -0
- package/dist/codegen/docs-pages.d.ts.map +1 -1
- package/dist/codegen/docs-pages.js +1 -0
- package/dist/codegen/docs-sections.d.ts +2 -0
- package/dist/codegen/docs-sections.d.ts.map +1 -1
- package/dist/codegen/docs-sections.js +8 -0
- package/dist/codegen/docs-types.d.ts +7 -0
- package/dist/codegen/docs-types.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/docs.js +20 -0
- package/dist/fold/subset.d.ts +3 -1
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/fold/subset.js +3 -1
- package/dist/graph-ir.d.ts +43 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +27 -0
- package/dist/lexicon.d.ts +25 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lint/rules/evl004-spread-non-const.d.ts.map +1 -1
- package/dist/lint/rules/evl004-spread-non-const.js +19 -1
- package/dist/wave-runtime.mjs +222 -206
- package/dist/workspace/checks/links.d.ts +5 -0
- package/dist/workspace/checks/links.d.ts.map +1 -1
- package/dist/workspace/checks/links.js +54 -8
- package/dist/workspace/checks.d.ts +4 -1
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +6 -2
- package/dist/workspace/compose-graph.d.ts +4 -1
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/compose-graph.js +3 -2
- package/dist/workspace/dbt-project-reader.d.ts +36 -0
- package/dist/workspace/dbt-project-reader.d.ts.map +1 -0
- package/dist/workspace/dbt-project-reader.js +290 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/graph-cli.js +3 -1
- package/dist/workspace/graph.schema.json +41 -3
- package/dist/workspace/intent.js +7 -2
- package/dist/workspace/kind-link-readers.d.ts +84 -0
- package/dist/workspace/kind-link-readers.d.ts.map +1 -0
- package/dist/workspace/kind-link-readers.js +53 -0
- package/dist/workspace/kinds.d.ts +15 -0
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/kinds.js +11 -0
- package/dist/workspace/lexicon-outputs.d.ts +63 -0
- package/dist/workspace/lexicon-outputs.d.ts.map +1 -0
- package/dist/workspace/lexicon-outputs.js +241 -0
- package/dist/workspace/links.d.ts +29 -7
- package/dist/workspace/links.d.ts.map +1 -1
- package/dist/workspace/links.js +109 -34
- package/dist/workspace/member-handles.d.ts +18 -1
- package/dist/workspace/member-handles.d.ts.map +1 -1
- package/dist/workspace/member-handles.js +31 -14
- package/dist/workspace/records-checkout.d.ts +11 -1
- package/dist/workspace/records-checkout.d.ts.map +1 -1
- package/dist/workspace/records-checkout.js +38 -2
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/status.js +3 -2
- package/dist/workspace/workspace-kinds.schema.json +5 -0
- package/package.json +1 -1
- package/src/audit/rules-doc.ts +1 -1
- package/src/cli/handlers/graph.ts +22 -1
- package/src/codegen/docs-pages.ts +1 -0
- package/src/codegen/docs-sections.ts +8 -0
- package/src/codegen/docs-site/AgentPrompt.astro +153 -0
- package/src/codegen/docs-types.ts +7 -0
- package/src/codegen/docs.ts +27 -0
- package/src/fold/subset.ts +3 -1
- package/src/graph-ir.test.ts +23 -1
- package/src/graph-ir.ts +65 -0
- package/src/lexicon.ts +26 -1
- package/src/lint/rules/evl004-spread-non-const.test.ts +18 -0
- package/src/lint/rules/evl004-spread-non-const.ts +16 -1
- package/src/wave-loop-golden.test.ts +3 -0
- package/src/workspace/checks/links.ts +57 -8
- package/src/workspace/checks.e2e.test.ts +1 -1
- package/src/workspace/checks.ts +9 -2
- package/src/workspace/compose-graph.ts +5 -3
- package/src/workspace/dbt-kind.e2e.test.ts +95 -0
- package/src/workspace/dbt-kind.test.ts +283 -0
- package/src/workspace/dbt-project-reader.ts +291 -0
- package/src/workspace/graph-cli.ts +3 -1
- package/src/workspace/graph.schema.json +41 -3
- package/src/workspace/intent.ts +7 -2
- package/src/workspace/kind-link-readers.ts +109 -0
- package/src/workspace/kinds.ts +21 -1
- package/src/workspace/lexicon-outputs.ts +272 -0
- package/src/workspace/links.test.ts +22 -0
- package/src/workspace/links.ts +129 -34
- package/src/workspace/member-handles.ts +28 -8
- package/src/workspace/records-checkout.ts +36 -2
- package/src/workspace/records-uncommitted.test.ts +5 -1
- package/src/workspace/status.ts +3 -2
- package/src/workspace/workspace-kinds.schema.json +5 -0
|
@@ -2,7 +2,7 @@ import { dirname, resolve } from "node:path";
|
|
|
2
2
|
import { discoverOps } from "../../op/discover";
|
|
3
3
|
import { discover } from "../../discovery/index";
|
|
4
4
|
import { partitionByLexicon, computeStackGraph, build, mergeBuildRootEntities } from "../../build";
|
|
5
|
-
import { applyLexiconGraphEdges, buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, GRAPH_IR_VERSION, type GraphIR, type IRPipeline, type LexiconGraphEdges, type LiveObservation } from "../../graph-ir";
|
|
5
|
+
import { applyLexiconGraphEdges, applyLexiconOutputs, buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, GRAPH_IR_VERSION, type GraphIR, type IRPipeline, type LexiconGraphEdges, type LexiconWorkspaceOutputs, type LiveObservation } from "../../graph-ir";
|
|
6
6
|
import { behaviourOverlay } from "../../behaviour-overlay";
|
|
7
7
|
import type { SerializerResult } from "../../serializer";
|
|
8
8
|
import { applyBehaviourOverlay, behaviourRequestFromIr } from "../../behaviour-graph";
|
|
@@ -746,6 +746,26 @@ function withLexiconEdges(ir: GraphIR, plugins: LexiconPlugin[], entities: Map<s
|
|
|
746
746
|
return reports.length === 0 ? ir : applyLexiconGraphEdges(ir, reports);
|
|
747
747
|
}
|
|
748
748
|
|
|
749
|
+
/**
|
|
750
|
+
* Add the outputs the lexicons' `workspaceOutputs` hooks report (#3931) to
|
|
751
|
+
* the IR's `exports`: a sql table and each of its columns, which `chant
|
|
752
|
+
* workspace graph` resolves member links against. A hook that throws costs
|
|
753
|
+
* its own outputs and a warning, not the graph.
|
|
754
|
+
*/
|
|
755
|
+
function withLexiconOutputs(ir: GraphIR, plugins: LexiconPlugin[], entities: Map<string, import("../../declarable").Declarable>): GraphIR {
|
|
756
|
+
const reports: LexiconWorkspaceOutputs[] = [];
|
|
757
|
+
for (const p of plugins) {
|
|
758
|
+
if (!p.workspaceOutputs) continue;
|
|
759
|
+
try {
|
|
760
|
+
const report = p.workspaceOutputs(entities);
|
|
761
|
+
if (report) reports.push(report);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
console.error(formatWarning({ message: `the ${p.name} lexicon's workspaceOutputs failed (${err instanceof Error ? err.message : String(err)}); its outputs are left out` }));
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return reports.length === 0 ? ir : applyLexiconOutputs(ir, reports);
|
|
767
|
+
}
|
|
768
|
+
|
|
749
769
|
/** Returned instead of a graph when the prediction cannot be attempted honestly. */
|
|
750
770
|
const REFUSED = Symbol("behaviour-refused");
|
|
751
771
|
|
|
@@ -888,6 +908,7 @@ async function runGraphView(
|
|
|
888
908
|
? ctx.plugins
|
|
889
909
|
: await loadPlugins(await projectLexiconsOrNone(projectPath));
|
|
890
910
|
ir = withLexiconEdges(ir, plugins as LexiconPlugin[], result.entities);
|
|
911
|
+
ir = withLexiconOutputs(ir, plugins as LexiconPlugin[], result.entities);
|
|
891
912
|
const predicted = await predictOntoIr(ir, plugins as LexiconPlugin[], ctx.args, {
|
|
892
913
|
// No environment on the declared path: the file is not deployed anywhere
|
|
893
914
|
// yet, and naming one would claim it was.
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* - `group`: a nested subgroup label inside the quadrant (e.g. "Vendor Composites").
|
|
17
17
|
* - `order`: number; lower sorts first within its group. Unordered pages follow, by label.
|
|
18
18
|
* - `hidden`: `true` keeps the page out of the sidebar (reachable by URL only).
|
|
19
|
+
* - `prompt`: optional `|` block; the agent prompt shown under the title. Kept.
|
|
19
20
|
*
|
|
20
21
|
* `group` / `order` / `hidden` / `label` are stripped from the written copy so
|
|
21
22
|
* Starlight's schema never sees them; `diataxis` stays, and the generated
|
|
@@ -8,6 +8,13 @@ import { escapeMdx } from "./docs-file-markers";
|
|
|
8
8
|
import { intrinsicFolds } from "../lexicon";
|
|
9
9
|
import type { DocsConfig, ManifestJSON, MetaEntry, RuleMeta } from "./docs-types";
|
|
10
10
|
|
|
11
|
+
/** A `prompt: |` block scalar for a page's front matter, or nothing. */
|
|
12
|
+
export function promptFrontmatter(prompt: string | undefined): string[] {
|
|
13
|
+
const text = prompt?.trim();
|
|
14
|
+
if (!text) return [];
|
|
15
|
+
return ["prompt: |", ...text.split("\n").map((line) => (line ? ` ${line}` : ""))];
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
export function generateOverview(
|
|
12
19
|
config: DocsConfig,
|
|
13
20
|
manifest: ManifestJSON,
|
|
@@ -20,6 +27,7 @@ export function generateOverview(
|
|
|
20
27
|
"---",
|
|
21
28
|
`title: "${config.displayName}"`,
|
|
22
29
|
`description: "${config.description}"`,
|
|
30
|
+
...promptFrontmatter(config.prompt),
|
|
23
31
|
"---",
|
|
24
32
|
"",
|
|
25
33
|
config.overview ?? `Reference documentation for the **${config.displayName}** lexicon.`,
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
---
|
|
2
|
+
// "Optional: hand this page to your coding agent": a card under the page title
|
|
3
|
+
// with the first lines of the page's `prompt:` front matter showing, a button
|
|
4
|
+
// that copies the whole prompt, one that copies a link to the card, and a
|
|
5
|
+
// toggle that shows it all. The main docs site and every lexicon docs site
|
|
6
|
+
// render it from their PageTitle override; lexicon sites get a copy of this
|
|
7
|
+
// file from writeDocsSite (docs.ts). The content schema refuses a prompt with
|
|
8
|
+
// no "Never" line. No Starlight import here, so the file works from any site.
|
|
9
|
+
interface Props {
|
|
10
|
+
text?: string;
|
|
11
|
+
}
|
|
12
|
+
const prompt = (Astro.props.text ?? Astro.locals.starlightRoute?.entry.data.prompt)?.trim();
|
|
13
|
+
---
|
|
14
|
+
{prompt && (
|
|
15
|
+
<figure class="chant-prompt not-content" id="agent-prompt">
|
|
16
|
+
<figcaption>Optional: hand this page to your coding agent</figcaption>
|
|
17
|
+
<details>
|
|
18
|
+
<summary>
|
|
19
|
+
<pre class="peek" aria-hidden="true">{prompt}</pre>
|
|
20
|
+
<span class="more">Show the whole prompt</span>
|
|
21
|
+
</summary>
|
|
22
|
+
<pre class="full"><code>{prompt}</code></pre>
|
|
23
|
+
</details>
|
|
24
|
+
<div class="actions">
|
|
25
|
+
<button type="button" class="copy" data-copy={prompt} data-done="Copied">Copy prompt</button>
|
|
26
|
+
<button type="button" class="link" data-link data-done="Link copied">Copy link</button>
|
|
27
|
+
</div>
|
|
28
|
+
</figure>
|
|
29
|
+
)}
|
|
30
|
+
|
|
31
|
+
<script>
|
|
32
|
+
const flash = (button: HTMLButtonElement, text: string) => {
|
|
33
|
+
const was = button.textContent;
|
|
34
|
+
button.textContent = text;
|
|
35
|
+
setTimeout(() => (button.textContent = was), 1600);
|
|
36
|
+
};
|
|
37
|
+
for (const button of document.querySelectorAll<HTMLButtonElement>('.chant-prompt button')) {
|
|
38
|
+
button.addEventListener('click', async () => {
|
|
39
|
+
const card = button.closest('figure')!;
|
|
40
|
+
const text = button.dataset.link !== undefined ? `${location.origin}${location.pathname}#${card.id}` : (button.dataset.copy ?? '');
|
|
41
|
+
try {
|
|
42
|
+
await navigator.clipboard.writeText(text);
|
|
43
|
+
flash(button, button.dataset.done ?? 'Copied');
|
|
44
|
+
} catch {
|
|
45
|
+
// No clipboard (an insecure origin, a denied permission): open the
|
|
46
|
+
// prompt and select it so the reader can copy it by hand.
|
|
47
|
+
card.querySelector('details')?.setAttribute('open', '');
|
|
48
|
+
const code = card.querySelector('.full code');
|
|
49
|
+
if (code) window.getSelection()?.selectAllChildren(code);
|
|
50
|
+
flash(button, 'Selected');
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<style>
|
|
57
|
+
/* Minimal: zinc greys from Starlight's scale and one blue. */
|
|
58
|
+
.chant-prompt {
|
|
59
|
+
--chant-prompt-blue: #2563eb;
|
|
60
|
+
margin: 1.25rem 0 0;
|
|
61
|
+
width: min(100%, 44rem);
|
|
62
|
+
border: 1px solid var(--sl-color-gray-5);
|
|
63
|
+
border-left: 2px solid var(--chant-prompt-blue);
|
|
64
|
+
border-radius: 0.5rem;
|
|
65
|
+
background: var(--sl-color-gray-7, var(--sl-color-black));
|
|
66
|
+
overflow: hidden;
|
|
67
|
+
}
|
|
68
|
+
:global([data-theme='dark']) .chant-prompt {
|
|
69
|
+
--chant-prompt-blue: #60a5fa;
|
|
70
|
+
}
|
|
71
|
+
figcaption {
|
|
72
|
+
padding: 0.7rem 1rem 0.4rem;
|
|
73
|
+
font-size: 0.85rem;
|
|
74
|
+
font-weight: 600;
|
|
75
|
+
color: var(--sl-color-white);
|
|
76
|
+
}
|
|
77
|
+
summary {
|
|
78
|
+
display: block;
|
|
79
|
+
list-style: none;
|
|
80
|
+
cursor: pointer;
|
|
81
|
+
padding: 0 1rem;
|
|
82
|
+
}
|
|
83
|
+
summary::-webkit-details-marker {
|
|
84
|
+
display: none;
|
|
85
|
+
}
|
|
86
|
+
pre {
|
|
87
|
+
margin: 0;
|
|
88
|
+
font-family: var(--sl-font-mono);
|
|
89
|
+
font-size: 0.75rem;
|
|
90
|
+
line-height: 1.55;
|
|
91
|
+
color: var(--sl-color-gray-2);
|
|
92
|
+
white-space: pre-wrap;
|
|
93
|
+
overflow-wrap: anywhere;
|
|
94
|
+
background: none;
|
|
95
|
+
border: 0;
|
|
96
|
+
padding: 0;
|
|
97
|
+
}
|
|
98
|
+
/* Two lines of the prompt, fading out, say it is there. */
|
|
99
|
+
.peek {
|
|
100
|
+
max-height: 2.4rem;
|
|
101
|
+
overflow: hidden;
|
|
102
|
+
mask-image: linear-gradient(to bottom, #000 40%, transparent);
|
|
103
|
+
}
|
|
104
|
+
.more,
|
|
105
|
+
details[open] summary::after {
|
|
106
|
+
display: inline-block;
|
|
107
|
+
margin-top: 0.2rem;
|
|
108
|
+
font-size: 0.72rem;
|
|
109
|
+
color: var(--chant-prompt-blue);
|
|
110
|
+
}
|
|
111
|
+
details[open] .peek,
|
|
112
|
+
details[open] .more {
|
|
113
|
+
display: none;
|
|
114
|
+
}
|
|
115
|
+
details[open] summary::after {
|
|
116
|
+
content: 'Hide the prompt';
|
|
117
|
+
}
|
|
118
|
+
.full {
|
|
119
|
+
padding: 0.25rem 1rem 0;
|
|
120
|
+
}
|
|
121
|
+
.actions {
|
|
122
|
+
display: flex;
|
|
123
|
+
gap: 0.5rem;
|
|
124
|
+
padding: 0.7rem 1rem 0.8rem;
|
|
125
|
+
}
|
|
126
|
+
button {
|
|
127
|
+
font: inherit;
|
|
128
|
+
font-size: 0.78rem;
|
|
129
|
+
font-weight: 600;
|
|
130
|
+
border-radius: 0.4rem;
|
|
131
|
+
padding: 0.35rem 0.85rem;
|
|
132
|
+
cursor: pointer;
|
|
133
|
+
}
|
|
134
|
+
.copy {
|
|
135
|
+
color: #fff;
|
|
136
|
+
background: var(--chant-prompt-blue);
|
|
137
|
+
border: 1px solid var(--chant-prompt-blue);
|
|
138
|
+
}
|
|
139
|
+
:global([data-theme='dark']) .copy {
|
|
140
|
+
color: #09090b;
|
|
141
|
+
}
|
|
142
|
+
.copy:hover {
|
|
143
|
+
filter: brightness(1.1);
|
|
144
|
+
}
|
|
145
|
+
.link {
|
|
146
|
+
color: var(--sl-color-white);
|
|
147
|
+
background: none;
|
|
148
|
+
border: 1px solid var(--sl-color-gray-5);
|
|
149
|
+
}
|
|
150
|
+
.link:hover {
|
|
151
|
+
border-color: var(--chant-prompt-blue);
|
|
152
|
+
}
|
|
153
|
+
</style>
|
|
@@ -21,6 +21,13 @@ export interface DocsConfig {
|
|
|
21
21
|
packageJsonPath?: string;
|
|
22
22
|
/** Lexicon-specific overview content (markdown) */
|
|
23
23
|
overview?: string;
|
|
24
|
+
/**
|
|
25
|
+
* The overview page's agent prompt: "Optional: hand this page to your coding
|
|
26
|
+
* agent", shown under the title (docs-site/AgentPrompt.astro). Imperative
|
|
27
|
+
* steps, what to report back, and a line starting "Never". An authored page
|
|
28
|
+
* under `pagesDir` carries its own as a `prompt:` front-matter field.
|
|
29
|
+
*/
|
|
30
|
+
prompt?: string;
|
|
24
31
|
/** Output format description (e.g. "CloudFormation JSON template") */
|
|
25
32
|
outputFormat?: string;
|
|
26
33
|
/** Custom service grouping from resource type (e.g. "AWS::S3::Bucket" → "S3") */
|
package/src/codegen/docs.ts
CHANGED
|
@@ -390,6 +390,13 @@ export const collections = {
|
|
|
390
390
|
extend: z.object({
|
|
391
391
|
// Diátaxis quadrant (https://diataxis.fr), chant #1731.
|
|
392
392
|
diataxis: z.enum(['tutorial', 'how-to', 'reference', 'explanation']).optional(),
|
|
393
|
+
// "Optional: hand this page to your coding agent", shown under the
|
|
394
|
+
// title by src/components/AgentPrompt.astro. It has to say what the
|
|
395
|
+
// agent must never do.
|
|
396
|
+
prompt: z
|
|
397
|
+
.string()
|
|
398
|
+
.optional()
|
|
399
|
+
.refine((p) => !p || /^Never\\b/m.test(p), 'a prompt needs a line that starts with "Never" (what the agent must not do)'),
|
|
393
400
|
}),
|
|
394
401
|
}),
|
|
395
402
|
}),
|
|
@@ -404,6 +411,25 @@ export const collections = {
|
|
|
404
411
|
);
|
|
405
412
|
copyFileSync(pluginSrcPath, join(outDir, "src", "rehype-base-url.mjs"));
|
|
406
413
|
|
|
414
|
+
// src/components/: the agent prompt card from chant core, and a PageTitle
|
|
415
|
+
// override that puts it under Starlight's title on every page with a `prompt:`.
|
|
416
|
+
mkdirSync(join(outDir, "src", "components"), { recursive: true });
|
|
417
|
+
copyFileSync(
|
|
418
|
+
fileURLToPath(new URL("./docs-site/AgentPrompt.astro", import.meta.url)),
|
|
419
|
+
join(outDir, "src", "components", "AgentPrompt.astro"),
|
|
420
|
+
);
|
|
421
|
+
writeFileSync(
|
|
422
|
+
join(outDir, "src", "components", "PageTitle.astro"),
|
|
423
|
+
`---
|
|
424
|
+
// Generated by chant's writeDocsSite. Starlight's page title, then the page's agent prompt.
|
|
425
|
+
import Default from '@astrojs/starlight/components/PageTitle.astro';
|
|
426
|
+
import AgentPrompt from './AgentPrompt.astro';
|
|
427
|
+
---
|
|
428
|
+
<Default />
|
|
429
|
+
<AgentPrompt />
|
|
430
|
+
`,
|
|
431
|
+
);
|
|
432
|
+
|
|
407
433
|
// astro.config.mjs
|
|
408
434
|
const rehypeLine = config.basePath
|
|
409
435
|
? `\n markdown: {\n rehypePlugins: [[rehypeBaseUrl, { base: '${config.basePath}', projectBase: '/chant' }]],\n },`
|
|
@@ -419,6 +445,7 @@ export default defineConfig({${config.basePath ? `\n base: '${config.basePath}'
|
|
|
419
445
|
integrations: [
|
|
420
446
|
starlight({
|
|
421
447
|
title: '${config.displayName}',
|
|
448
|
+
components: { PageTitle: './src/components/PageTitle.astro' },
|
|
422
449
|
sidebar: ${JSON.stringify(sidebar, null, 6).replace(/\n/g, "\n ")},
|
|
423
450
|
}),
|
|
424
451
|
],
|
package/src/fold/subset.ts
CHANGED
|
@@ -141,7 +141,9 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
|
|
|
141
141
|
* (`{...n}`) is shape-valid (`n` is a plain identifier) but `fold()`
|
|
142
142
|
* rejects it once it discovers `n` folds to a number, not an object.
|
|
143
143
|
* Only real evaluation catches this; EVL004 independently narrows
|
|
144
|
-
* spread sources to a stricter "traceable to a const" shape
|
|
144
|
+
* spread sources to a stricter "traceable to a const" shape (a const
|
|
145
|
+
* declared in the file, or an imported binding, which fold() resolves
|
|
146
|
+
* from the exporting module's folded value), which
|
|
145
147
|
* catches most real-world misuse without evaluating, but is not a
|
|
146
148
|
* full substitute.
|
|
147
149
|
*
|
package/src/graph-ir.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { buildGraphIr } from "./graph-ir";
|
|
2
|
+
import { applyLexiconOutputs, buildGraphIr } from "./graph-ir";
|
|
3
3
|
import { DECLARABLE_MARKER, type Declarable } from "./declarable";
|
|
4
4
|
import { AttrRef } from "./attrref";
|
|
5
5
|
import { LexiconOutput } from "./lexicon-output";
|
|
@@ -370,3 +370,25 @@ describe("whole declarables held in an entity's fields (#3257)", () => {
|
|
|
370
370
|
expect(ir.edges).toEqual([{ from: "lb", to: "vpc", kind: "ref", viaAttr: "actions" }]);
|
|
371
371
|
});
|
|
372
372
|
});
|
|
373
|
+
|
|
374
|
+
describe("applyLexiconOutputs (#3931)", () => {
|
|
375
|
+
test("adds each reported output to exports on the node that declares it, keeping a hand-written export", () => {
|
|
376
|
+
const orders = decl({ lexicon: "sql", entityType: "Postgres::Table", props: {} });
|
|
377
|
+
const ir = buildGraphIr(new Map<string, Declarable>([["orders", orders]]));
|
|
378
|
+
const withOutputs = applyLexiconOutputs({ ...ir, exports: [{ name: "public.orders", node: "orders", attr: "name" }] }, [
|
|
379
|
+
{
|
|
380
|
+
outputs: [
|
|
381
|
+
{ name: "public.orders", entity: "orders" },
|
|
382
|
+
{ name: "public.orders.buyer_id", entity: "orders", previously: ["public.orders.customer_id"] },
|
|
383
|
+
{ name: "other.thing", entity: "notANode" },
|
|
384
|
+
],
|
|
385
|
+
},
|
|
386
|
+
]);
|
|
387
|
+
expect(withOutputs.exports).toEqual([
|
|
388
|
+
{ name: "other.thing" },
|
|
389
|
+
{ name: "public.orders", node: "orders", attr: "name" },
|
|
390
|
+
{ name: "public.orders.buyer_id", node: "orders", previously: ["public.orders.customer_id"] },
|
|
391
|
+
]);
|
|
392
|
+
expect(applyLexiconOutputs(ir, [{ outputs: [] }])).toBe(ir);
|
|
393
|
+
});
|
|
394
|
+
});
|
package/src/graph-ir.ts
CHANGED
|
@@ -155,6 +155,43 @@ export interface LexiconGraphReference {
|
|
|
155
155
|
dangling?: boolean;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* One output a lexicon reports for its own declarations (#3931): a name a
|
|
160
|
+
* workspace link may target, such as a sql table's `public.orders` or a
|
|
161
|
+
* column's `public.orders.customer_id`.
|
|
162
|
+
*/
|
|
163
|
+
export interface LexiconWorkspaceOutput {
|
|
164
|
+
/** The output's name, as a link's `output` names it. */
|
|
165
|
+
name: string;
|
|
166
|
+
/** The entity key that declares it. */
|
|
167
|
+
entity: string;
|
|
168
|
+
/**
|
|
169
|
+
* Names the output had before, from a rename hint in source (a sql
|
|
170
|
+
* `-- previously:` comment). A link to one of them is told where it went.
|
|
171
|
+
*/
|
|
172
|
+
previously?: string[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One file the reader was handed but could not read without running it (#3931). */
|
|
176
|
+
export interface LexiconUnreadSource {
|
|
177
|
+
/** The file's path, as the caller names it in messages. */
|
|
178
|
+
file: string;
|
|
179
|
+
text: string;
|
|
180
|
+
/** Why it was not read: the fold's located reason. */
|
|
181
|
+
reason: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** What a lexicon's `workspaceOutputs` hook returns (#3931). */
|
|
185
|
+
export interface LexiconWorkspaceOutputs {
|
|
186
|
+
outputs: LexiconWorkspaceOutput[];
|
|
187
|
+
/**
|
|
188
|
+
* Why the list may be incomplete, one line each: a declaration whose name
|
|
189
|
+
* can't be known statically, a file of this lexicon's that did not fold, a
|
|
190
|
+
* view whose columns can't be derived. Empty or absent when it is complete.
|
|
191
|
+
*/
|
|
192
|
+
gaps?: string[];
|
|
193
|
+
}
|
|
194
|
+
|
|
158
195
|
/** What a lexicon's `graphEdges` hook returns (#3910). */
|
|
159
196
|
export interface LexiconGraphEdges {
|
|
160
197
|
references: LexiconGraphReference[];
|
|
@@ -304,6 +341,8 @@ export interface IRExport {
|
|
|
304
341
|
value?: unknown;
|
|
305
342
|
/** The deployed stack that publishes it, on an observed graph (#1279). */
|
|
306
343
|
stack?: string;
|
|
344
|
+
/** Names the output had before, from a rename hint a lexicon read (#3931). */
|
|
345
|
+
previously?: string[];
|
|
307
346
|
}
|
|
308
347
|
|
|
309
348
|
/** A cross-stack import this stack consumes — a `Parameter` fed from another
|
|
@@ -834,6 +873,32 @@ export function applyLexiconGraphEdges(ir: GraphIR, reports: readonly LexiconGra
|
|
|
834
873
|
return out;
|
|
835
874
|
}
|
|
836
875
|
|
|
876
|
+
/**
|
|
877
|
+
* Add the outputs the lexicons' `workspaceOutputs` hooks report (#3931) to a
|
|
878
|
+
* built IR's `exports`, each on the node that declares it. An output whose
|
|
879
|
+
* name is already exported (a hand-written `output()`) keeps the existing
|
|
880
|
+
* export. Pure. Returns `ir` itself when no report added anything.
|
|
881
|
+
*/
|
|
882
|
+
export function applyLexiconOutputs(ir: GraphIR, reports: readonly LexiconWorkspaceOutputs[]): GraphIR {
|
|
883
|
+
const nodeIds = new Set(ir.nodes.map((n) => n.id));
|
|
884
|
+
const names = new Set((ir.exports ?? []).map((e) => e.name));
|
|
885
|
+
const added: IRExport[] = [];
|
|
886
|
+
for (const report of reports) {
|
|
887
|
+
for (const o of report.outputs) {
|
|
888
|
+
if (names.has(o.name)) continue;
|
|
889
|
+
names.add(o.name);
|
|
890
|
+
added.push({
|
|
891
|
+
name: o.name,
|
|
892
|
+
...(nodeIds.has(o.entity) ? { node: o.entity } : {}),
|
|
893
|
+
...(o.previously && o.previously.length > 0 ? { previously: [...o.previously] } : {}),
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (added.length === 0) return ir;
|
|
898
|
+
const exports = [...(ir.exports ?? []), ...added].sort((a, b) => a.name.localeCompare(b.name));
|
|
899
|
+
return { ...ir, exports };
|
|
900
|
+
}
|
|
901
|
+
|
|
837
902
|
function danglingKey(d: IRDanglingRef): string {
|
|
838
903
|
return `${d.from}\0${d.path}\0${d.value}`;
|
|
839
904
|
}
|
package/src/lexicon.ts
CHANGED
|
@@ -23,7 +23,7 @@ import type { OwnershipChannel, OwnershipMarker } from "./ownership";
|
|
|
23
23
|
import type { LexiconConfigSchema } from "./lexicon-config";
|
|
24
24
|
import type { RuleMeta } from "./audit/catalog";
|
|
25
25
|
import type { ReferenceCatalog } from "./graph-refs";
|
|
26
|
-
import type { IREdge, LexiconGraphEdges } from "./graph-ir";
|
|
26
|
+
import type { IREdge, LexiconGraphEdges, LexiconUnreadSource, LexiconWorkspaceOutputs } from "./graph-ir";
|
|
27
27
|
import type { DescribeResourcesResult, UnobservedReason } from "./observation";
|
|
28
28
|
import type { DescribeIdentityOptions, DescribeIdentityResult } from "./identity";
|
|
29
29
|
import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
|
|
@@ -1450,6 +1450,31 @@ export interface LexiconPlugin {
|
|
|
1450
1450
|
*/
|
|
1451
1451
|
graphEdges?(entities: ReadonlyMap<string, Declarable>): LexiconGraphEdges | undefined;
|
|
1452
1452
|
|
|
1453
|
+
/**
|
|
1454
|
+
* The workspace outputs this lexicon's declarations expose (#3931): names
|
|
1455
|
+
* another member's link may target, without anyone writing `output(...)`.
|
|
1456
|
+
* The sql lexicon answers each table and view as `<schema>.<table>` and
|
|
1457
|
+
* each column as `<schema>.<table>.<column>`.
|
|
1458
|
+
*
|
|
1459
|
+
* Called two ways, with the same answer expected from both:
|
|
1460
|
+
*
|
|
1461
|
+
* - by `chant workspace check`, offline, with the entities the member's
|
|
1462
|
+
* source folds to (`foldProject` under `sandbox`: no member code runs),
|
|
1463
|
+
* and `unread` listing the files that did not fold, with their text. A
|
|
1464
|
+
* file of this lexicon's among them is a gap: the list is then incomplete
|
|
1465
|
+
* and a link to a name not in it is unresolved rather than missing;
|
|
1466
|
+
* - by `chant graph --format ir`, with the built entities and no `unread`.
|
|
1467
|
+
* The outputs land on the IR's `exports`, which `chant workspace graph`
|
|
1468
|
+
* resolves links against.
|
|
1469
|
+
*
|
|
1470
|
+
* Names come from the declarations as written in source, never from an
|
|
1471
|
+
* environment's or profile's rendering, so they stay the same everywhere
|
|
1472
|
+
* the member deploys. Pure: no I/O. Return `undefined` when the project
|
|
1473
|
+
* declares nothing this lexicon reports. Omit for lexicons with no such
|
|
1474
|
+
* declarations.
|
|
1475
|
+
*/
|
|
1476
|
+
workspaceOutputs?(entities: ReadonlyMap<string, Declarable>, unread?: readonly LexiconUnreadSource[]): LexiconWorkspaceOutputs | undefined;
|
|
1477
|
+
|
|
1453
1478
|
// LSP
|
|
1454
1479
|
/** Provide completions for LSP */
|
|
1455
1480
|
completionProvider?(ctx: CompletionContext): CompletionItem[];
|
|
@@ -36,6 +36,24 @@ describe("EVL004: spread-non-const", () => {
|
|
|
36
36
|
expect(evl004SpreadNonConstRule.check(ctx)).toHaveLength(0);
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
+
test("allows spread from an imported binding (named, default, namespace member)", () => {
|
|
40
|
+
const ctx = createContext(`
|
|
41
|
+
import base, { steps } from "./shared";
|
|
42
|
+
import * as shared from "./shared";
|
|
43
|
+
const x = { ...base, ...shared.preset };
|
|
44
|
+
const y = [...steps, { run: "true" }];
|
|
45
|
+
`);
|
|
46
|
+
expect(evl004SpreadNonConstRule.check(ctx)).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("still flags a call on an imported function", () => {
|
|
50
|
+
const ctx = createContext(`
|
|
51
|
+
import { makeSteps } from "./shared";
|
|
52
|
+
const y = [...makeSteps("web")];
|
|
53
|
+
`);
|
|
54
|
+
expect(evl004SpreadNonConstRule.check(ctx)).toHaveLength(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
39
57
|
test("flags spread from let variable", () => {
|
|
40
58
|
const ctx = createContext(`
|
|
41
59
|
let base = { a: 1 };
|
|
@@ -8,6 +8,11 @@ import { isInsideCompositeFactory } from "./composite-scope";
|
|
|
8
8
|
* Spread expressions ({...x} or [...x]) must reference a const-declared
|
|
9
9
|
* variable, object literal, or property access chain from a const.
|
|
10
10
|
* Spreading from function calls or non-traceable sources is blocked.
|
|
11
|
+
*
|
|
12
|
+
* A binding imported from another module counts as const: an import can't
|
|
13
|
+
* be reassigned, and sharing an exported const (a preset, a list of
|
|
14
|
+
* Dockerfile steps) across modules is the point of exporting it. Whether the
|
|
15
|
+
* exporting module declared it `const` is that module's own EVL concern.
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
function isTraceableToConst(node: ts.Node, sourceFile: ts.SourceFile): boolean {
|
|
@@ -40,8 +45,18 @@ function isTraceableToConst(node: ts.Node, sourceFile: ts.SourceFile): boolean {
|
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
function isConstIdentifier(id: ts.Identifier, sourceFile: ts.SourceFile): boolean {
|
|
43
|
-
// Walk all statements looking for a const declaration of this identifier
|
|
48
|
+
// Walk all statements looking for a const declaration or an import of this identifier
|
|
44
49
|
for (const stmt of sourceFile.statements) {
|
|
50
|
+
if (ts.isImportDeclaration(stmt) && stmt.importClause && !stmt.importClause.isTypeOnly) {
|
|
51
|
+
const clause = stmt.importClause;
|
|
52
|
+
if (clause.name?.text === id.text) return true;
|
|
53
|
+
const bindings = clause.namedBindings;
|
|
54
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
55
|
+
if (bindings.elements.some((el) => !el.isTypeOnly && el.name.text === id.text)) return true;
|
|
56
|
+
} else if (bindings && ts.isNamespaceImport(bindings) && bindings.name.text === id.text) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
45
60
|
if (ts.isVariableStatement(stmt)) {
|
|
46
61
|
if (stmt.declarationList.flags & ts.NodeFlags.Const) {
|
|
47
62
|
for (const decl of stmt.declarationList.declarations) {
|
|
@@ -43,6 +43,9 @@ beforeEach(() => {
|
|
|
43
43
|
// A CI job's locator would land on every pending fact.
|
|
44
44
|
vi.stubEnv("GITHUB_ACTIONS", "");
|
|
45
45
|
vi.stubEnv("GITLAB_CI", "");
|
|
46
|
+
// A pull request's address would land on every pending fact too.
|
|
47
|
+
vi.stubEnv("GITHUB_REF_NAME", "");
|
|
48
|
+
vi.stubEnv("CI_MERGE_REQUEST_IID", "");
|
|
46
49
|
});
|
|
47
50
|
afterEach(() => vi.unstubAllEnvs());
|
|
48
51
|
|
|
@@ -17,31 +17,58 @@
|
|
|
17
17
|
* | WSP096 | a consumer states the same link twice |
|
|
18
18
|
* | WSP097 | an entry lists `outputs` for a kind that doesn't take them from the entry |
|
|
19
19
|
* | WSP098 | a link states a `protocol` and its kind is not `telemetry` (#2558) |
|
|
20
|
+
* | WSP099 | a kind's link reader could not read part of a member's files without guessing (#3932) |
|
|
21
|
+
*
|
|
22
|
+
* Links a kind's reader finds in a member's own files (#3932), such as a dbt
|
|
23
|
+
* project's sources, are checked by WSP093 to WSP095 like declared ones, and
|
|
24
|
+
* their findings name the member's file and line instead of the declaration.
|
|
20
25
|
*/
|
|
21
26
|
|
|
22
27
|
import type { WorkspaceCheck, WorkspaceCheckContext, WorkspaceDiagnostic } from "../checks";
|
|
23
28
|
import type { Member } from "../declaration";
|
|
24
29
|
import { sourceMemberHandles } from "../member-handles";
|
|
30
|
+
import { readKindLinks, type KindLinkRead } from "../kind-link-readers";
|
|
25
31
|
import { DEFAULT_LINK_KIND, LINK_KINDS, TELEMETRY_LINK_KIND, linkTargetProblem, resolveLinks, type AmbiguousRow, type LinkRow, type LinkTableRow } from "../links";
|
|
26
32
|
|
|
27
33
|
const tables = new WeakMap<WorkspaceCheckContext, LinkTableRow[]>();
|
|
34
|
+
const reads = new WeakMap<WorkspaceCheckContext, Map<string, KindLinkRead>>();
|
|
35
|
+
|
|
36
|
+
/** What each member's kind reader found (#3932), read once per check run. */
|
|
37
|
+
function kindReads(ctx: WorkspaceCheckContext): Map<string, KindLinkRead> {
|
|
38
|
+
let read = reads.get(ctx);
|
|
39
|
+
if (!read) {
|
|
40
|
+
read = readKindLinks(ctx.declaration, ctx.tree, ctx.kinds);
|
|
41
|
+
reads.set(ctx, read);
|
|
42
|
+
}
|
|
43
|
+
return read;
|
|
44
|
+
}
|
|
28
45
|
|
|
29
46
|
/** The link rows for a check run, resolved once and shared by every link check. */
|
|
30
47
|
export function linkTable(ctx: WorkspaceCheckContext): LinkTableRow[] {
|
|
31
48
|
let rows = tables.get(ctx);
|
|
32
49
|
if (!rows) {
|
|
33
|
-
rows = resolveLinks(ctx.declaration, sourceMemberHandles(ctx.declaration, ctx.tree, ctx.groups, ctx.kinds));
|
|
50
|
+
rows = resolveLinks(ctx.declaration, sourceMemberHandles(ctx.declaration, ctx.tree, ctx.groups, ctx.kinds, ctx.facts?.lexiconOutputs), undefined, kindReads(ctx));
|
|
34
51
|
tables.set(ctx, rows);
|
|
35
52
|
}
|
|
36
53
|
return rows;
|
|
37
54
|
}
|
|
38
55
|
|
|
39
|
-
|
|
56
|
+
/** The declared links, and the links a kind's reader found: the rows a member states. */
|
|
57
|
+
const statedRows = (ctx: WorkspaceCheckContext) => linkTable(ctx).filter((r): r is LinkRow => r.status !== "ambiguous" && r.origin !== "inferred:joinKey");
|
|
40
58
|
|
|
41
|
-
|
|
42
|
-
|
|
59
|
+
/** A finding at a declared link's field in the declaration, or at the file and line a reader's link came from. */
|
|
60
|
+
function linkFinding(check: WorkspaceCheck, ctx: WorkspaceCheckContext, row: LinkTableRow, field: string, message: string): WorkspaceDiagnostic {
|
|
61
|
+
const base = { checkId: check.id, severity: check.severity, message, entity: row.consumer };
|
|
62
|
+
if (row.origin === "kind-reader" && row.file !== undefined) {
|
|
63
|
+
const member = ctx.declaration.members.find((m) => m.name === row.consumer);
|
|
64
|
+
return { ...base, pointer: member?.pointer ?? "", treeFile: row.file, line: row.line ?? 1, column: 1 };
|
|
65
|
+
}
|
|
66
|
+
return { ...base, pointer: `${(row as LinkRow).pointer}/${field}` };
|
|
43
67
|
}
|
|
44
68
|
|
|
69
|
+
/** Where a reader's link came from, for its message; empty for a declared link. */
|
|
70
|
+
const statedAt = (row: LinkRow) => (row.origin === "kind-reader" ? ` (read by ${row.reader} from ${row.file}:${row.line})` : "");
|
|
71
|
+
|
|
45
72
|
const describeLink = (row: LinkRow) => `${row.consumer}'s link to ${row.producer} output ${row.output}`;
|
|
46
73
|
|
|
47
74
|
export const LINK_CHECKS: readonly WorkspaceCheck[] = [
|
|
@@ -87,9 +114,9 @@ export const LINK_CHECKS: readonly WorkspaceCheck[] = [
|
|
|
87
114
|
severity: "error",
|
|
88
115
|
configurable: true,
|
|
89
116
|
check(ctx) {
|
|
90
|
-
return
|
|
117
|
+
return statedRows(ctx)
|
|
91
118
|
.filter((r) => r.status === "missing")
|
|
92
|
-
.map((r) => linkFinding(this, r, "output", `${describeLink(r)} does not resolve: ${r.reason}`));
|
|
119
|
+
.map((r) => linkFinding(this, ctx, r, "output", `${describeLink(r)}${statedAt(r)} does not resolve: ${r.reason}`));
|
|
93
120
|
},
|
|
94
121
|
},
|
|
95
122
|
{
|
|
@@ -99,9 +126,9 @@ export const LINK_CHECKS: readonly WorkspaceCheck[] = [
|
|
|
99
126
|
severity: "info",
|
|
100
127
|
configurable: true,
|
|
101
128
|
check(ctx) {
|
|
102
|
-
return
|
|
129
|
+
return statedRows(ctx)
|
|
103
130
|
.filter((r) => r.status === "unresolved")
|
|
104
|
-
.map((r) => linkFinding(this, r, "output", `${describeLink(r)} is kept unresolved: ${r.reason}`));
|
|
131
|
+
.map((r) => linkFinding(this, ctx, r, "output", `${describeLink(r)}${statedAt(r)} is kept unresolved: ${r.reason}`));
|
|
105
132
|
},
|
|
106
133
|
},
|
|
107
134
|
{
|
|
@@ -115,6 +142,10 @@ export const LINK_CHECKS: readonly WorkspaceCheck[] = [
|
|
|
115
142
|
return linkTable(ctx)
|
|
116
143
|
.filter((r): r is AmbiguousRow => r.status === "ambiguous")
|
|
117
144
|
.map((r) => {
|
|
145
|
+
if (r.origin === "kind-reader") {
|
|
146
|
+
const choices = r.candidates.map((c) => c.producer).join(", ");
|
|
147
|
+
return linkFinding(this, ctx, r, "", `${r.consumer}'s link to ${r.input} (read by ${r.reader}) has no single producer: ${r.reason}${choices ? `. Candidates: ${choices}` : ""}`);
|
|
148
|
+
}
|
|
118
149
|
const choices = r.candidates.map((c) => `${c.producer} output ${c.output} (${c.label})`).join(", ");
|
|
119
150
|
const first = r.candidates[0];
|
|
120
151
|
return {
|
|
@@ -201,4 +232,22 @@ export const LINK_CHECKS: readonly WorkspaceCheck[] = [
|
|
|
201
232
|
);
|
|
202
233
|
},
|
|
203
234
|
},
|
|
235
|
+
{
|
|
236
|
+
id: "WSP099",
|
|
237
|
+
name: "kind-reader-gap",
|
|
238
|
+
description:
|
|
239
|
+
"A kind's link reader read every link in a member's files that it could read without guessing (#3932). A value it would have to render, such as a Jinja template in a dbt source, is reported here with its file and line, and the links it would state are not checked.",
|
|
240
|
+
severity: "info",
|
|
241
|
+
configurable: true,
|
|
242
|
+
check(ctx) {
|
|
243
|
+
const out: WorkspaceDiagnostic[] = [];
|
|
244
|
+
for (const [member, read] of kindReads(ctx)) {
|
|
245
|
+
const pointer = ctx.declaration.members.find((m) => m.name === member)?.pointer ?? "";
|
|
246
|
+
for (const gap of read.gaps) {
|
|
247
|
+
out.push({ checkId: this.id, severity: this.severity, message: `${member} (read by ${read.reader}): ${gap.message}`, entity: member, pointer, treeFile: gap.file, line: gap.line, column: 1 });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
},
|
|
252
|
+
},
|
|
204
253
|
];
|
|
@@ -75,7 +75,7 @@ describe("the WSP catalog", () => {
|
|
|
75
75
|
expect(list.filter((id) => /^WSP(07|08|09|10)\d$/.test(id))).toEqual([
|
|
76
76
|
"WSP071", "WSP072", "WSP073",
|
|
77
77
|
"WSP081", "WSP082", "WSP083",
|
|
78
|
-
"WSP091", "WSP092", "WSP093", "WSP094", "WSP095", "WSP096", "WSP097", "WSP098",
|
|
78
|
+
"WSP091", "WSP092", "WSP093", "WSP094", "WSP095", "WSP096", "WSP097", "WSP098", "WSP099",
|
|
79
79
|
"WSP101", "WSP102", "WSP103", "WSP104", "WSP105", "WSP106",
|
|
80
80
|
]);
|
|
81
81
|
});
|