Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Schemas. The two MCP scenarios are the conformance fixtures: identical
`condition` that is the **shared Decision-7 grammar**, not option-bound logic.
**No configuration payload** hangs off an option — the v3 `option.data` /
`JSON.parse(option.data)` overload does **not** exist (it is absent from
`question.schema.json`); configuration lives in `descriptor.optionsSchema` and
`questions.schema.json`); configuration lives in `descriptor.optionsSchema` and
computed fields in the provider `derived.<id>.<key>` namespace (§5). Exactly
one of `staticOptions` / `optionsFrom`
per option-bearing question. `optionsFrom` names an engine-owned provider
Expand Down
9 changes: 9 additions & 0 deletions docs/02-architecture/scaffolding.backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,15 @@ decided by ADR-0014 … ADR-0019:
(b) is clean but needs two invocations. The ADR-0014 dispatcher
*resolution* contract does not depend on which is chosen; only the help
UX does.
- **v3 parity gap — DA api-spec route not `KiotaNPMIntegration`-gated
(recorded, not fixed).** v3 `daProjectTypeNode` chooses `apiSpecWithSearchNode()`
(url/file/search split) vs `apiSpecNode()` (single `singleFileOrText`) on
`FeatureFlags.KiotaNPMIntegration` (default `"true"`). The v4 `openapi`
selector route always targets the split-form `da/api-plugin-from-existing-api`.
Default-consistent (flag on → both split); diverges only when the flag is off.
Full parity would add a flag-gated route + a `singleFileOrText` DA api-plugin
variant (mirrors the mcp DT split `da/mcp-server` vs `da/mcp-server-static`).
(Found 2026-07-07.)

## 3. Visual Studio multi-project surface

Expand Down
6 changes: 6 additions & 0 deletions docs/03-specs/operations/scaffolding/collect-create-inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ flowchart TD
walked by the surface-neutral driver, and the common floor questions are
operation-owned normalized `QuestionSpec`s in the same walk. Neither is ever
rehydrated into a v3 `IQTreeNode` (ADR-0016 decision 6 / collect-inputs INV-1).
A `questions` array item may also be a `{ "use": "<name>" }` reference to a
shared fragment under `v4/_shared/questions/<name>.json`; the loader splices the
fragment's own `questions` in place (recursively; bare-name / cycle guarded) so
the walk always sees one flat `QuestionSpec[]`. This is authoring-time reuse
only (e.g. the `llm-service` fragment shared by the custom-copilot / CEA
templates) — it changes neither the resolved questions nor any behavior.
- **INV-3** — A v4 identity-only `OptionItem` carries no configuration payload
across the bridge (no v3 `option.data`); only its `id` round-trips
(collect-inputs INV-2).
Expand Down
18 changes: 13 additions & 5 deletions docs/03-specs/operations/scaffolding/walk-create-selector.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ else:
1. **The presentation read** — for full-package floor bytes,
`openCreateSelectorPresentation(bytes)` projects `v4/create/selector.json`
onto a `SelectorPresentation` (each question's `title` / `placeholder` /
`staticOptions[{ id, label, detail?, groupName?, condition? }]`). For a staged
`keyPrefix?` / `staticOptions[{ id, label, detail?, groupName?, keyPrefix?,
condition? }]`). For a staged
selector artifact, the same projection runs over the standalone JSON bytes.
It is the presentation sibling of `openCreateSelector` /
`openSelectorFromJsonBytes` (which keep only `{ name, condition }` for
Expand All @@ -56,7 +57,10 @@ else:
`RouteQuestion`, looks up its presentation by `name`, filters its
`staticOptions` by each option's environment `condition` (the shared
evaluator over a `{ surface }` scope + the injected feature-flag reader),
renders the survivors via `UserInteraction.selectOption`, and returns the
renders the survivors via `UserInteraction.selectOption` — localizing each
question's `title` / `placeholder` and each option's `label` / `detail` /
`groupName` through its `keyPrefix` (the shared `localizePrefixedText` over
the NLS bundle, with the authored literal as fallback) — and returns the
chosen `id`. A surface cancellation surfaces as the `Result` error.
3. **The port assembly + run** — build the shared question-walk port from the
prompt face, the shared validator registry, and the shared evaluator; route
Expand All @@ -68,9 +72,12 @@ else:
It does **not** translate the answers to v3 inputs, does **not** ask Q2, does
**not** scaffold, and adds **no** routing grammar — every question `condition`,
option `condition`, and route `when` is the one shared `evaluate-expression`
grammar (resolve-build-target INV-3). Option labels are the authored English
fallback; `keyPrefix` localization is a tracked follow-up and rides the same
presentation, so it does not change this operation's shape.
grammar (resolve-build-target INV-3). User-visible text localizes through each
question's / option's `keyPrefix` (`<keyPrefix>.{title|placeholder|label|detail|
groupName}` in the NLS bundle) with the authored English literal as fallback —
the same shared `localizePrefixedText` the Q2 + common-floor bridge uses
([`collect-create-inputs`](collect-create-inputs.md)), so Q1 and Q2/Q3 share one
localization mechanism.

## Inputs

Expand Down Expand Up @@ -122,6 +129,7 @@ presentation, so it does not change this operation's shape.
| WCS-11 | L1 | the floor, `interactive=false`, `prefilled={ projectType:"copilot-agent-type" }` (missing `daTemplate` / `actionSource`), a UI that throws if called | `runCreateSelector` | `err` — a `UserError` naming the missing required dimension; **no** `ui.selectOption` call, **no** silent route coercion (resolve-build-target AC-03b) |
| WCS-12 | L1 | the real shipped floor, `surface="vscode"`, `flagReader(TEAMSFX_AGENT_SKILLS)=false`, a scripted UI reaching `daTemplate` (after `projectType=copilot-agent-type`) | `runCreateSelector` (prompt face) | `ui.selectOption` is offered the `daTemplate` options **without** `skill` — the option-level `featureFlag('TEAMSFX_AGENT_SKILLS')` condition filters it; the always-on options (e.g. `no-action`) remain |
| WCS-13 | L1 | the floor, `flagReader(TEAMSFX_AGENT_SKILLS)=true`, a scripted UI picking `projectType=copilot-agent-type` → `daTemplate=skill` | `runCreateSelector` | the `skill` option **is offered**; `ok` `BuildTarget` `{ templateId:"da/skill", engine:"v4" }`; the walk ends at `daTemplate` (no `actionSource` — that is `add-action` only) and `answers` carry both picks; the route's `featureFlag('TEAMSFX_AGENT_SKILLS')` gate is honored |
| WCS-23 | L1 | the real shipped floor, `surface="vscode"`, a scripted UI picking `projectType=copilot-agent-type` → `daTemplate=no-action` | `runCreateSelector` (prompt face) | the `projectType` prompt localizes its `title` and each option's `label` / `detail` via `<keyPrefix>.{title\|label\|detail}` from the NLS bundle — e.g. `blank-app-type` renders its NLS label (which differs from the authored literal `"Blank App"`) with the authored icon preserved — falling back to the authored literal when no key is registered |
| WCS-18 | L1 | the floor, a scripted UI picking `projectType=copilot-agent-type` → `daTemplate=typespec` | `runCreateSelector` | `ok` `BuildTarget` `{ templateId:"da/typespec", engine:"v4" }`; the walk ends at `daTemplate` and `answers` carry both picks |
| WCS-19 | L1 | the floor, a scripted UI picking `projectType=copilot-agent-type` → `daTemplate=graph-connector` | `runCreateSelector` | `ok` `BuildTarget` `{ templateId:"da/graph-connector", engine:"v4" }`; the walk ends at `daTemplate` and `answers` carry both picks |
| WCS-20 | L1 | the floor, a scripted UI picking `projectType=graph-connector-type` | `runCreateSelector` | `ok` `BuildTarget` `{ templateId:"graph-connector", engine:"v4" }`; the walk ends at `projectType` and `answers` carry that pick |
Expand Down
2 changes: 1 addition & 1 deletion packages/fx-core/src/common/secretmasker/feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ export function extractFeatures(text: string): Token[] {
const allTokens = tokenize(text);
const featureTokens = allTokens.filter((t) => t.type === "feature");
for (let i = 0; i < featureTokens.length; i++) {
const tokenObj = featureTokens[i] as FeatureToken;
const tokenObj = featureTokens[i];
let token = tokenObj.token;

if (token.endsWith("<secret>")) {
Expand Down
6 changes: 2 additions & 4 deletions packages/fx-core/src/component/coordinator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ class Coordinator {
projectModel.registerApp,
projectModel.provision,
projectModel.configureApp,
].filter((c) => c !== undefined) as ILifecycle[];
].filter((c) => c !== undefined);

// 2. check each cycle
for (const cycle of cycles) {
Expand Down Expand Up @@ -330,9 +330,7 @@ class Coordinator {
return err(maybeProjectModel.error);
}
const projectModel = maybeProjectModel.value;
const cycles: ILifecycle[] = [projectModel.provision].filter(
(c) => c !== undefined
) as ILifecycle[];
const cycles: ILifecycle[] = [projectModel.provision].filter((c) => c !== undefined);

let unresolvedPlaceholders: string[] = [];
// 2. check each cycle
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ export class DotnetChecker implements DepsChecker {
const dotnetSdks: DotnetSDK[] = await this.searchDotnetSdks(dotnetExecPath);
const installedVersions = dotnetSdks
.map((sdk) => DotnetChecker.parseDotnetVersion(sdk.version))
.filter((version) => version !== null) as string[];
.filter((version) => version !== null);
return this.isDotnetVersionsInstalled(installedVersions);
} catch (error) {
const errorMessage = `validate private install failed, error = '${
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,7 @@ export class CopilotGptManifestUtils {

const capability = agentManifest.capabilities.find(
(cap) => cap.name === DeclarativeCopilotCapabilityName.OneDriveAndSharePoint
) as OneDriveAndSharePointCapability | undefined;
);

if (items_by_url) {
newCapabilityData.items_by_url = capability ? capability.items_by_url || [] : [];
Expand Down Expand Up @@ -728,7 +728,7 @@ export class CopilotGptManifestUtils {
};
const capability = agentManifest.capabilities.find(
(cap) => cap.name === DeclarativeCopilotCapabilityName.WebSearch
) as WebSearchCapability | undefined;
);

// del capability warning
if (items_by_url === null && capability?.sites?.length) {
Expand Down
5 changes: 2 additions & 3 deletions packages/fx-core/src/component/utils/envUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ class EnvUtil {
const list = await fs.readdir(envFolderPath);
let envs = list
.map((fileName) => this.extractEnvNameFromFileName(fileName))
.filter((env) => env !== undefined) as string[];
.filter((env) => env !== undefined);
if (remoteOnly) {
envs = envs.filter((env) => environmentNameManager.isRemoteEnvironment(env));
}
Expand Down Expand Up @@ -295,8 +295,7 @@ export const envUtil = new EnvUtil();

const NEW_LINE_SPLITTER = /\r?\n/;
type DotenvParsedLine =
| string
| { key: string; value: string; comment?: string; quote?: '"' | "'" };
string | { key: string; value: string; comment?: string; quote?: '"' | "'" };
interface DotenvParseResult {
lines?: DotenvParsedLine[];
obj: DotenvOutput;
Expand Down
2 changes: 1 addition & 1 deletion packages/fx-core/src/question/other.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1132,7 +1132,7 @@ export function apiSpecFromPluginManifestQuestion(): SingleSelectQuestion {
const pluginManifest = (await fs.readJson(pluginManifestPath)) as PluginManifestSchema;
const specs = pluginManifest
.runtimes!.filter((runtime) => runtime.type === "OpenApi")
.map((runtime) => runtime.spec.url as string);
.map((runtime) => runtime.spec.url);
return [...new Set(specs)];
},
};
Expand Down
10 changes: 10 additions & 0 deletions packages/fx-core/src/v4/buildTarget/parseSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export interface PresentationOption {
detail?: string;
groupName?: string;
iconPath?: string;
keyPrefix?: string;
condition?: ConditionNode;
}

Expand All @@ -139,6 +140,7 @@ export interface PresentationQuestion {
name: string;
title?: string;
placeholder?: string;
keyPrefix?: string;
staticOptions: PresentationOption[];
}

Expand Down Expand Up @@ -173,6 +175,10 @@ function parsePresentationOption(raw: unknown): Result<PresentationOption, FxErr
if (iconPath !== undefined) {
option.iconPath = iconPath;
}
const keyPrefix = stringField(raw, "keyPrefix");
if (keyPrefix !== undefined) {
option.keyPrefix = keyPrefix;
}
const condition = raw.condition;
if (condition !== undefined) {
if (!isExpressionNode(condition)) {
Expand Down Expand Up @@ -201,6 +207,10 @@ function parsePresentationQuestion(raw: unknown): Result<PresentationQuestion, F
if (placeholder !== undefined) {
question.placeholder = placeholder;
}
const keyPrefix = stringField(raw, "keyPrefix");
if (keyPrefix !== undefined) {
question.keyPrefix = keyPrefix;
}
if (raw.staticOptions !== undefined) {
if (!Array.isArray(raw.staticOptions)) {
return err(userError(`selector question '${name}' has a non-array 'staticOptions'`));
Expand Down
1 change: 1 addition & 0 deletions packages/fx-core/src/v4/collectInputs/collectInputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface QuestionSpec {
placeholder?: string;
prompt?: string;
default?: string;
password?: boolean;
filters?: Record<string, string[]>;
inputOptionItem?: OptionItem;
inputBoxConfig?: InputBoxConfig;
Expand Down
44 changes: 3 additions & 41 deletions packages/fx-core/src/v4/distribution/createQuestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,20 @@

import { FxError, SystemError } from "@microsoft/teamsfx-api";
import AdmZip from "adm-zip";
import { Result, err, ok } from "neverthrow";
import { Result, err } from "neverthrow";
import { QuestionSpec } from "../collectInputs/collectInputs";
import { DeclarativeLocator } from "../model/dataModel";
import { resolveQuestions, zipFragmentReader } from "./questionFragments";

/** Load one create template's `questions.json`. See collect-create-inputs spec. */

const SOURCE = "Scaffold";

/** The native question kinds `questions.json` may declare (collect-inputs `QuestionType`). */
const QUESTION_TYPES: ReadonlySet<string> = new Set([
"singleSelect",
"multiSelect",
"text",
"confirm",
"singleFile",
"folder",
"singleFileOrText",
]);

/** The `v4/<kind>/<templateId>/questions.json` entry this locator resolves to. */
function questionsEntry(locator: DeclarativeLocator): string {
return `v4/${locator.kind}/${locator.templateId}/questions.json`;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/** Predicate-narrow a parsed `questions` array without an unchecked cast. */
function isQuestionSpecArray(value: unknown): value is QuestionSpec[] {
return (
Array.isArray(value) &&
value.every(
(item) =>
isRecord(item) &&
typeof item.name === "string" &&
typeof item.type === "string" &&
QUESTION_TYPES.has(item.type)
)
);
}

export function openCreateQuestions(
bytes: Buffer,
locator: DeclarativeLocator
Expand Down Expand Up @@ -98,15 +70,5 @@ export function openCreateQuestions(
);
}

if (!isRecord(parsed) || !isQuestionSpecArray(parsed.questions)) {
return err(
new SystemError({
source: SOURCE,
name: "PackageFileInvalid",
message: `The template package file "${entryPath}" must be an object with a "questions" array.`,
})
);
}

return ok(parsed.questions);
return resolveQuestions(parsed, entryPath, zipFragmentReader(zip));
}
47 changes: 6 additions & 41 deletions packages/fx-core/src/v4/distribution/declarativePackage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Result, err, ok } from "neverthrow";
import { QuestionSpec } from "../collectInputs/collectInputs";
import { DeclarativeLocator, TemplateFileEntry } from "../model/dataModel";
import { LoadedPackage } from "./packageDir";
import { resolveQuestions, zipFragmentReader } from "./questionFragments";

/** Open one declarative package subtree from channel zip bytes. See open-template-package spec. */

Expand Down Expand Up @@ -47,46 +48,6 @@ function missingFile(file: string): FxError {
});
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

const QUESTION_TYPES: ReadonlySet<string> = new Set([
"singleSelect",
"multiSelect",
"text",
"confirm",
"singleFile",
"folder",
"singleFileOrText",
]);

function isQuestionSpecArray(value: unknown): value is QuestionSpec[] {
return (
Array.isArray(value) &&
value.every(
(item) =>
isRecord(item) &&
typeof item.name === "string" &&
typeof item.type === "string" &&
QUESTION_TYPES.has(item.type)
)
);
}

function parseQuestions(raw: unknown, file: string): Result<QuestionSpec[], FxError> {
if (!isRecord(raw) || !isQuestionSpecArray(raw.questions)) {
return err(
new SystemError({
source: SOURCE,
name: "PackageFileInvalid",
message: `The template package file "${file}" must be an object with a "questions" array.`,
})
);
}
return ok(raw.questions);
}

interface PackageMetadataEntries {
descriptorRaw?: string;
questionsRaw?: string;
Expand Down Expand Up @@ -230,7 +191,11 @@ export function openDeclarativePackageMetadata(
if (questionsRaw.isErr()) {
return err(questionsRaw.error);
}
const questions = parseQuestions(questionsRaw.value, `${root}questions.json`);
const questions = resolveQuestions(
questionsRaw.value,
`${root}questions.json`,
zipFragmentReader(zip.value)
);
if (questions.isErr()) {
return err(questions.error);
}
Expand Down
Loading
Loading