@cspeach/cli 1.1.35 → 1.1.36
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/.gitattributes +1 -0
- package/dist/agent/loop.js +6 -0
- package/dist/agent/parallel-write-guard.js +3 -1
- package/dist/commands/plan-audit-evidence.js +6 -1
- package/dist/commands/plan-gate.js +10 -1
- package/dist/commands/plan-resume.js +9 -0
- package/dist/one-shot.js +3 -0
- package/dist/projects/extract-plan.js +6 -0
- package/dist/projects/plan-objects.js +1 -1
- package/dist/projects/plan-skeleton.js +93 -0
- package/dist/projects/save-command.js +17 -0
- package/dist/rap/finish-checks.js +389 -0
- package/dist/rap/generator-names.js +57 -0
- package/dist/rap/skeleton-card.js +31 -0
- package/dist/rap/skeleton-rules.js +38 -0
- package/dist/rap/skeleton-session.js +21 -0
- package/dist/rap/skeleton-store.js +40 -0
- package/dist/renderer/tool-labels.js +4 -1
- package/dist/repl/rule8-detector.js +3 -2
- package/dist/repl.js +5 -2
- package/dist/sap/generator-fact.js +21 -0
- package/dist/session/audit-export.js +19 -1
- package/dist/tools/rap/finish-check.js +118 -0
- package/dist/tools/rap/generate-objects.js +317 -0
- package/dist/tools/rap/skeleton-resolve.js +283 -0
- package/dist/tools/tool-loading.js +7 -1
- package/dist/tools/write-mode.js +2 -0
- package/package.json +2 -2
package/.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
test/unit/recorded/golden/*.json -text
|
package/dist/agent/loop.js
CHANGED
|
@@ -44,6 +44,7 @@ import { consumeTodoSetThisTurn } from '../ui/todo-emitter.js';
|
|
|
44
44
|
import { runSaveCommand, runPromoteCommand, detectArtifactSkill, offerAnswerBlockers } from '../projects/index.js';
|
|
45
45
|
import { planLaneBlockForTurn } from '../projects/plan-lane.js';
|
|
46
46
|
import { clearAppFlow, getAppFlow, setGapConfirmed, startAppFlow } from '../lane/app-flow.js';
|
|
47
|
+
import { getLatestSkeletonDecision } from '../rap/skeleton-session.js';
|
|
47
48
|
import { afterRebuildTurn } from '../journey/rebuild.js';
|
|
48
49
|
import { afterJourneyTurn, gateJourneyForTurn, journeyAsk, journeyCardConfirm, journeyStoppedOnError, journeyTurnBlock, pauseJourneyForInterruptedTurn } from '../journey/chain.js';
|
|
49
50
|
import { addJourneyLeftOut, addJourneySkips, getJourneyFlow } from '../journey/flow.js';
|
|
@@ -427,6 +428,7 @@ export async function maybeOfferSave(p) {
|
|
|
427
428
|
// the skill it saves (the /abap-plan turn that got the lane block); a plan
|
|
428
429
|
// found in another skill's output (detectArtifactSkill) never gets it.
|
|
429
430
|
...(p.lane && p.skill === saveSkill ? { lane: p.lane } : {}),
|
|
431
|
+
...(p.latestSkeleton && p.skill === saveSkill ? { latestSkeleton: p.latestSkeleton } : {}),
|
|
430
432
|
onNotSaved: (reason) => { notSaved = reason; },
|
|
431
433
|
...(p.onProjectId ? { onProjectId: p.onProjectId } : {}),
|
|
432
434
|
// Never headless: a headless journey queues nothing.
|
|
@@ -1841,6 +1843,10 @@ ${imageExpansion.error}`));
|
|
|
1841
1843
|
const flowLane = getAppFlow(session)?.lane;
|
|
1842
1844
|
return flowLane ? { lane: { ...flowLane, cwd: params.ctx.cwd } } : {};
|
|
1843
1845
|
})(),
|
|
1846
|
+
...(() => {
|
|
1847
|
+
const sk = getLatestSkeletonDecision(session);
|
|
1848
|
+
return sk ? { latestSkeleton: { path: sk.path, sha256: sk.sha256 } } : {};
|
|
1849
|
+
})(),
|
|
1844
1850
|
// Sessions/folders (Task 6) — the first save tags the session with
|
|
1845
1851
|
// its folder's project id, only when the session belongs to this
|
|
1846
1852
|
// folder (ruling 18). Saved to disk right after the hook.
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Scope of "write-class" matches the spec for this bug fix and is narrower
|
|
23
23
|
* than tools/write-mode.ts WRITE_TOOLS: only the four tools that perform
|
|
24
|
-
* net-new source mutation are gated here.
|
|
24
|
+
* net-new source mutation (and the RAP generator, which creates objects) are gated here.
|
|
25
25
|
*/
|
|
26
26
|
/** Tools that mutate ABAP source. Narrower than WRITE_TOOLS — excludes
|
|
27
27
|
* activate / publish / transport_release / message_maintain. The bug
|
|
@@ -31,6 +31,8 @@ export const WRITE_CLASS_TOOLS = new Set([
|
|
|
31
31
|
'sap_update_method',
|
|
32
32
|
'sap_delete_object',
|
|
33
33
|
'sap_create_object',
|
|
34
|
+
// Final review I2 — SAP's RAP generator creates up to 9 objects in one call.
|
|
35
|
+
'sap_generate_objects',
|
|
34
36
|
]);
|
|
35
37
|
export function isWriteClassTool(name) {
|
|
36
38
|
return WRITE_CLASS_TOOLS.has(name);
|
|
@@ -44,8 +44,13 @@ import { checkpointsRoot } from '../agent/skill-checkpoint.js';
|
|
|
44
44
|
* contract branch depends on it — so it must NEVER be evicted by the row cap:
|
|
45
45
|
* PROTECTED, not the fillable existence tier. Matched EXACTLY (^…$ anchored) so
|
|
46
46
|
* a near-miss tool name can never masquerade as consent.
|
|
47
|
+
*
|
|
48
|
+
* RAP generator (2026-10-14): sap_generate_objects is a write (SAP's generator
|
|
49
|
+
* creates the whole stack) and rap_finish_check is the F1–F5 proof of a RAP
|
|
50
|
+
* phase — both PROTECTED in any phase. rap_finish_check is matched EXACTLY.
|
|
51
|
+
* The R26 rule for the fiori_* smokes (cap.* phases only) is unchanged.
|
|
47
52
|
*/
|
|
48
|
-
const PROTECTED_TOOL_RE = /^sap_(set_source|update_method|create_object|delete_object|activate|syntax_check|atc_run|inactive_objects|transport_create|transport_release|service_binding_publish|snapshot)|^request_approval$/;
|
|
53
|
+
const PROTECTED_TOOL_RE = /^sap_(set_source|update_method|create_object|delete_object|activate|syntax_check|atc_run|inactive_objects|transport_create|transport_release|service_binding_publish|snapshot|generate_objects)|^request_approval$|^rap_finish_check$/;
|
|
49
54
|
/**
|
|
50
55
|
* D-F (2026-07-05): EXISTENCE / VERIFY-READ evidence. A verify-only phase
|
|
51
56
|
* re-run (object already built in a prior attempt; this attempt proves it
|
|
@@ -64,9 +64,14 @@ let planPhaseActive = false;
|
|
|
64
64
|
* plan object-list grant covers requests only for THIS plan; a step started
|
|
65
65
|
* without an id (null) is never covered. Cleared with the flag. */
|
|
66
66
|
let activePlanId = null;
|
|
67
|
-
|
|
67
|
+
/* RAP generator final review M1 — the running plan's file, so a tool resolves
|
|
68
|
+
* the plan's pinned files (skeleton decision) from the plan's own root first,
|
|
69
|
+
* like the lane reader does. Cleared with the flag. */
|
|
70
|
+
let activePlanPath = null;
|
|
71
|
+
export function setPlanPhaseActive(active, planId, planPath) {
|
|
68
72
|
planPhaseActive = active;
|
|
69
73
|
activePlanId = active && typeof planId === 'string' && planId ? planId : null;
|
|
74
|
+
activePlanPath = active && typeof planPath === 'string' && planPath ? planPath : null;
|
|
70
75
|
}
|
|
71
76
|
export function isPlanPhaseActive() {
|
|
72
77
|
return planPhaseActive;
|
|
@@ -75,6 +80,10 @@ export function isPlanPhaseActive() {
|
|
|
75
80
|
export function getActivePlanId() {
|
|
76
81
|
return planPhaseActive ? activePlanId : null;
|
|
77
82
|
}
|
|
83
|
+
/** The running plan's file path, or null (no step, or started without one). */
|
|
84
|
+
export function getActivePlanPath() {
|
|
85
|
+
return planPhaseActive ? activePlanPath : null;
|
|
86
|
+
}
|
|
78
87
|
/* Task 7 — per-phase subagent dispatch counter. agent_run increments it on
|
|
79
88
|
* every dispatch made while a plan phase is active; the REPL reads it
|
|
80
89
|
* (read+reset) in the runTurn finally that clears the plan-phase flag and
|
|
@@ -59,6 +59,7 @@ import { FLOORPLAN_TO_TEMPLATE } from '../tools/fiori/floorplan-map.js';
|
|
|
59
59
|
import { getCurrentTransport, setCurrentTransport } from '../repl/current-transport.js';
|
|
60
60
|
import { askYesNo, isYes } from '../projects/yes-no.js';
|
|
61
61
|
import { formatAttention, formatInfo } from '../renderer/notice-shape.js';
|
|
62
|
+
import { skeletonWriteBackConflict } from '../projects/plan-skeleton.js';
|
|
62
63
|
import { checkLaneConsistency, checkRequiredDeploy, isDeployPhase, loadPlanLaneForRun, renderLaneBlock } from '../projects/plan-lane.js';
|
|
63
64
|
import { askDeployCard } from './deploy-card.js';
|
|
64
65
|
import { isCredentialHandoverApproved } from '../repl/credential-handover-gate.js';
|
|
@@ -925,6 +926,14 @@ export async function finishPlanResume(args) {
|
|
|
925
926
|
logNotSaved(args, laneConflict ? 'a step no longer fits the app type the plan was made for. Re-run /abap-plan to rebuild the plan.' : 'it dropped the deploy step this app needs', (laneConflict ?? deployConflict));
|
|
926
927
|
return null;
|
|
927
928
|
}
|
|
929
|
+
// RAP generator plan, Task 8 — the skeleton decision is fixed for the run:
|
|
930
|
+
// a write-back may not change, drop or add a phase's work.skeleton. A plan
|
|
931
|
+
// without one that stays without one is untouched (older plans, spec §8).
|
|
932
|
+
const skeletonConflict = priorParsed.ok ? skeletonWriteBackConflict(priorParsed.content, extract.content) : null;
|
|
933
|
+
if (skeletonConflict) {
|
|
934
|
+
logNotSaved(args, 'it changed the skeleton decision, which cannot change during the run. Re-run /abap-plan to change it.', skeletonConflict);
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
928
937
|
}
|
|
929
938
|
const nowIso = new Date().toISOString();
|
|
930
939
|
const statuses = extract.statuses;
|
package/dist/one-shot.js
CHANGED
|
@@ -63,6 +63,9 @@ import './tools/cap/metadata-import.js';
|
|
|
63
63
|
import './tools/cap/sap-link-tool.js';
|
|
64
64
|
import './tools/cap/link-smoke.js';
|
|
65
65
|
import './tools/lane/app-lane-resolve.js';
|
|
66
|
+
import './tools/rap/skeleton-resolve.js';
|
|
67
|
+
import './tools/rap/generate-objects.js';
|
|
68
|
+
import './tools/rap/finish-check.js';
|
|
66
69
|
import './tools/extend-model/tool.js';
|
|
67
70
|
import './tools/text-elements/tools.js';
|
|
68
71
|
import './tools/capability/tool.js';
|
|
@@ -84,6 +84,12 @@ function expandCompactContent(changedRaw, prior, rawStatuses) {
|
|
|
84
84
|
if (ow && typeof ow === 'object' && ow.plannedObjects === undefined && priorWork?.plannedObjects !== undefined) {
|
|
85
85
|
ow.plannedObjects = priorWork.plannedObjects;
|
|
86
86
|
}
|
|
87
|
+
// RAP generator plan, Task 8 — the skeleton decision is plan-create-only too:
|
|
88
|
+
// a step's write-back that omits it keeps it (plan-resume refuses a change).
|
|
89
|
+
const priorSkeleton = priorWork?.skeleton;
|
|
90
|
+
if (ow && typeof ow === 'object' && ow.skeleton === undefined && priorSkeleton !== undefined) {
|
|
91
|
+
ow.skeleton = priorSkeleton;
|
|
92
|
+
}
|
|
87
93
|
}
|
|
88
94
|
// 1:1 map over the PRIOR phase list — changed entries overlay their phase,
|
|
89
95
|
// everything else passes through verbatim from the envelope.
|
|
@@ -9,7 +9,7 @@ export const OBJECT_TYPE_LABELS = {
|
|
|
9
9
|
const NEVER_ON_LIST = new Set(['WAPA', 'CTS', 'DEVC']);
|
|
10
10
|
const TYPE_RE = /^[A-Z]{4}$/;
|
|
11
11
|
const NAME_RE = /^(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,40}$/;
|
|
12
|
-
const PKG_RE = /^(\$[A-Z0-9_]{1,29}|(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,30})$/;
|
|
12
|
+
export const PKG_RE = /^(\$[A-Z0-9_]{1,29}|(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,30})$/;
|
|
13
13
|
export const MAX_PLANNED_OBJECTS = 200;
|
|
14
14
|
/** ADT type code from a raw type: `'ddls/df'` → `'DDLS'`. */
|
|
15
15
|
export function normalizeObjectType(raw) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// cspeach-cli/src/projects/plan-skeleton.ts
|
|
2
|
+
//
|
|
3
|
+
// RAP generator plan, Task 8 — a RAP lane plan must carry the CLI's skeleton
|
|
4
|
+
// decision unchanged (spec §3.3). Checked at plan create; an older plan without
|
|
5
|
+
// a skeleton phase still resumes exactly as before (no regression, spec §8).
|
|
6
|
+
//
|
|
7
|
+
// - checkSkeletonDecision: at save. The phase whose `work.skeleton` holds the
|
|
8
|
+
// Plan block from rap_skeleton_resolve must name the builder the decision
|
|
9
|
+
// file (sha-pinned) records, and on the generator path list exactly the
|
|
10
|
+
// objects SAP's generator will create.
|
|
11
|
+
// - skeletonWriteBackConflict: at resume. A write-back may not change, drop or
|
|
12
|
+
// add a `work.skeleton` — the decision is fixed for the run.
|
|
13
|
+
import { cardLine } from '../lane/card.js';
|
|
14
|
+
import { readSkeletonDecision } from '../rap/skeleton-store.js';
|
|
15
|
+
import { toPlannedObject } from './plan-objects.js';
|
|
16
|
+
// Final review M3 — the developer reads these on screen ([save] lines, no
|
|
17
|
+
// detail split there): plain words only, no tool, field, layer or phase ids.
|
|
18
|
+
export const SKELETON_PHASE_MISSING = 'This RAP plan is missing its skeleton step. Run /abap-plan again.';
|
|
19
|
+
export const SKELETON_DECISION_STALE = 'This plan uses an older skeleton choice, not the newest one. Run /abap-plan again.';
|
|
20
|
+
export const SKELETON_CHANGED_ON_RESUME = 'The skeleton choice cannot change during a run. Run /abap-plan again.';
|
|
21
|
+
const SKELETON_TWO_PHASES = 'This plan has more than one skeleton step. Run /abap-plan again.';
|
|
22
|
+
const SKELETON_BUILDER_DIFFERS = 'This plan builds the skeleton differently from the choice on the skeleton card. Run /abap-plan again.';
|
|
23
|
+
const key = (o) => `${o.type} ${o.name} ${o.package ?? ''}`.trim();
|
|
24
|
+
const skeletonOf = (p) => p.work?.skeleton;
|
|
25
|
+
/** True when any phase carries a `work.skeleton`. */
|
|
26
|
+
export function hasSkeletonPhase(content) {
|
|
27
|
+
return content.phases.some((p) => !!skeletonOf(p));
|
|
28
|
+
}
|
|
29
|
+
export async function checkSkeletonDecision(content, rec, laneRoot, opts) {
|
|
30
|
+
if (rec.final.backend !== 'rap')
|
|
31
|
+
return null;
|
|
32
|
+
const phases = content.phases.filter((p) => !!skeletonOf(p));
|
|
33
|
+
if (phases.length === 0) {
|
|
34
|
+
// Fix round 1 — keyed on CLI-owned state only (`creating`, from the prior
|
|
35
|
+
// envelope), never on the model-written project.mode.
|
|
36
|
+
return opts.creating ? SKELETON_PHASE_MISSING : null;
|
|
37
|
+
}
|
|
38
|
+
if (phases.length > 1) {
|
|
39
|
+
return SKELETON_TWO_PHASES;
|
|
40
|
+
}
|
|
41
|
+
const phase = phases[0];
|
|
42
|
+
const work = phase.work;
|
|
43
|
+
const d = work.skeleton.decision;
|
|
44
|
+
// Fix round 1 — the plan must cite the decision rap_skeleton_resolve wrote
|
|
45
|
+
// last in this session (not an older sha-valid file, e.g. from before an
|
|
46
|
+
// owner override). No session decision (a later session): any sha-valid file.
|
|
47
|
+
if (opts.latest) {
|
|
48
|
+
const cited = String(d?.path ?? '').replace(/\\/g, '/');
|
|
49
|
+
if (cited !== opts.latest.path || String(d?.sha256 ?? '') !== opts.latest.sha256)
|
|
50
|
+
return SKELETON_DECISION_STALE;
|
|
51
|
+
}
|
|
52
|
+
const read = await readSkeletonDecision(laneRoot, String(d?.path ?? ''), String(d?.sha256 ?? ''));
|
|
53
|
+
if (!read.ok)
|
|
54
|
+
return read.message;
|
|
55
|
+
const decided = read.rec.decision.by;
|
|
56
|
+
if (work.skeleton.by !== decided) {
|
|
57
|
+
return SKELETON_BUILDER_DIFFERS;
|
|
58
|
+
}
|
|
59
|
+
if (decided === 'sap-generator') {
|
|
60
|
+
const norm = (list) => (Array.isArray(list) ? list : [])
|
|
61
|
+
.map(toPlannedObject).filter((o) => !!o).map(key);
|
|
62
|
+
const planned = new Set(norm(work.plannedObjects));
|
|
63
|
+
const wanted = new Set(norm(read.rec.objects));
|
|
64
|
+
const missing = [...wanted].find((k) => !planned.has(k));
|
|
65
|
+
const extra = [...planned].find((k) => !wanted.has(k));
|
|
66
|
+
if (missing || extra) {
|
|
67
|
+
const shown = (k) => cardLine(k.split(' ').slice(0, 2).join(' '));
|
|
68
|
+
return "This plan's skeleton step does not list exactly the objects SAP's generator will create. "
|
|
69
|
+
+ `${missing ? `Missing: ${shown(missing)}. ` : ''}${extra ? `Not created by the generator: ${shown(extra)}. ` : ''}Run /abap-plan again.`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
/** JSON with object keys sorted, so a re-ordered but equal skeleton compares equal. */
|
|
75
|
+
function stable(v) {
|
|
76
|
+
if (Array.isArray(v))
|
|
77
|
+
return `[${v.map(stable).join(',')}]`;
|
|
78
|
+
if (v && typeof v === 'object') {
|
|
79
|
+
const o = v;
|
|
80
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stable(o[k])}`).join(',')}}`;
|
|
81
|
+
}
|
|
82
|
+
return JSON.stringify(v) ?? 'null';
|
|
83
|
+
}
|
|
84
|
+
const skeletonMap = (c) => stable(c.phases.filter((p) => !!skeletonOf(p)).map((p) => [p.id, skeletonOf(p)]));
|
|
85
|
+
/**
|
|
86
|
+
* Resume write-back guard: every phase's `work.skeleton` must be exactly as it
|
|
87
|
+
* was (none changed, dropped or added). A plan with no skeleton that stays
|
|
88
|
+
* without one is untouched (older plans resume unchanged). Returns the plain
|
|
89
|
+
* refusal, or null.
|
|
90
|
+
*/
|
|
91
|
+
export function skeletonWriteBackConflict(prior, next) {
|
|
92
|
+
return skeletonMap(prior) === skeletonMap(next) ? null : SKELETON_CHANGED_ON_RESUME;
|
|
93
|
+
}
|
|
@@ -22,6 +22,8 @@ import { resolveProjectFolderSync, ensureProjectId } from './project-folder.js';
|
|
|
22
22
|
import { askYesNo, isYes } from './yes-no.js';
|
|
23
23
|
import { checkLaneConsistency, checkRequiredDeploy, LANE_CHANGED_AT_CREATE, readPlanLane } from './plan-lane.js';
|
|
24
24
|
import { specGapRefForSave } from './plan-sources.js';
|
|
25
|
+
import { checkSkeletonDecision, hasSkeletonPhase } from './plan-skeleton.js';
|
|
26
|
+
import { laneRootFor } from '../lane/roots.js';
|
|
25
27
|
const SKILL_REGISTRY = {
|
|
26
28
|
'abap-spec-gap': {
|
|
27
29
|
artefactType: 'spec-gap',
|
|
@@ -369,6 +371,21 @@ export async function runSaveCommand(args) {
|
|
|
369
371
|
args.log('[save] The plan was not saved.');
|
|
370
372
|
return null;
|
|
371
373
|
}
|
|
374
|
+
// RAP generator plan, Task 8 — a RAP plan holds to the CLI's skeleton
|
|
375
|
+
// decision. The file was written under laneRootFor(cwd) by
|
|
376
|
+
// rap_skeleton_resolve, so it is read from the same root here. A new plan
|
|
377
|
+
// (or a revision of one that already carried a skeleton) must have it; a
|
|
378
|
+
// revision of an older plan without one saves as before (spec §8).
|
|
379
|
+
const skel = await checkSkeletonDecision(planExtract.content, lr.rec, await laneRootFor(args.lane?.cwd ?? args.cwd), {
|
|
380
|
+
creating: !prior || (priorContent !== undefined && hasSkeletonPhase(priorContent)),
|
|
381
|
+
...(args.latestSkeleton ? { latest: args.latestSkeleton } : {}),
|
|
382
|
+
});
|
|
383
|
+
if (skel) {
|
|
384
|
+
args.log('');
|
|
385
|
+
args.log(`[save] ${skel}`);
|
|
386
|
+
args.log('[save] The plan was not saved.');
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
372
389
|
}
|
|
373
390
|
// C1 review fix — name the revision target BEFORE the confirm so the user
|
|
374
391
|
// knows what a 'y' will do. Log line only, deliberately NOT a second
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// cspeach-cli/src/rap/finish-checks.ts
|
|
2
|
+
//
|
|
3
|
+
// RAP generator plan, Task 10 — Finish items F1–F5 (spec 2026-10-14 §6),
|
|
4
|
+
// checked mechanically on the app's sources so the model cannot claim them
|
|
5
|
+
// done. Pure: the tool `rap_finish_check` reads the live sources and calls
|
|
6
|
+
// checkFinish. SAP's real generated skeleton is the failing fixture.
|
|
7
|
+
//
|
|
8
|
+
// Problem texts are short, plain sentences a developer understands; the only
|
|
9
|
+
// ids in them are object and field names.
|
|
10
|
+
//
|
|
11
|
+
// Fix round 1: comments (`//`, `--`, `/* */`) are stripped from the BDEF, CDS,
|
|
12
|
+
// DDLX and table sources before any rule reads them, so commented-out code never
|
|
13
|
+
// passes a check. A view field the parser cannot read is a problem, never a
|
|
14
|
+
// silent skip.
|
|
15
|
+
export const FINISH_TITLES = {
|
|
16
|
+
F1: 'Authorization',
|
|
17
|
+
F2: 'Access control',
|
|
18
|
+
F3: 'Admin fields',
|
|
19
|
+
F4: 'Labels',
|
|
20
|
+
F5: 'Coded fields',
|
|
21
|
+
};
|
|
22
|
+
const ADMIN_FIELDS = ['CreatedBy', 'CreatedAt', 'LastChangedBy', 'LastChangedAt', 'LocalLastChangedAt', 'LocalLastChangedBy'];
|
|
23
|
+
const item = (id, problems) => ({ id, ok: problems.length === 0, problems });
|
|
24
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
25
|
+
const eqi = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
26
|
+
const unreadable = (line, object) => `Could not read the field on line ${line} of ${object}; check it by hand`;
|
|
27
|
+
/**
|
|
28
|
+
* CDS / BDEF source without comments: `// …` and `-- …` to end of line, and
|
|
29
|
+
* `/* … *\/` blocks. Quoted literals ('…') are kept as they are. Line breaks
|
|
30
|
+
* inside a block comment are kept, so line numbers do not move.
|
|
31
|
+
*/
|
|
32
|
+
export function stripCdsComments(src) {
|
|
33
|
+
let out = '';
|
|
34
|
+
let i = 0;
|
|
35
|
+
const n = src.length;
|
|
36
|
+
while (i < n) {
|
|
37
|
+
const c = src[i];
|
|
38
|
+
const d = src[i + 1];
|
|
39
|
+
if (c === "'") {
|
|
40
|
+
const j = src.indexOf("'", i + 1);
|
|
41
|
+
const e = j < 0 ? n : j + 1;
|
|
42
|
+
out += src.slice(i, e);
|
|
43
|
+
i = e;
|
|
44
|
+
}
|
|
45
|
+
else if ((c === '/' && d === '/') || (c === '-' && d === '-')) {
|
|
46
|
+
while (i < n && src[i] !== '\n' && src[i] !== '\r')
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
else if (c === '/' && d === '*') {
|
|
50
|
+
const j = src.indexOf('*/', i + 2);
|
|
51
|
+
const e = j < 0 ? n : j + 2;
|
|
52
|
+
out += src.slice(i, e).replace(/[^\r\n]/g, '');
|
|
53
|
+
i = e;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
out += c;
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** Annotation lines directly above line index `i` of `lines`, joined. */
|
|
63
|
+
function annotationsAt(lines, i) {
|
|
64
|
+
const ann = [];
|
|
65
|
+
let depth = 0;
|
|
66
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
67
|
+
const t = lines[j].trim();
|
|
68
|
+
depth += (t.match(/[\]}]/g)?.length ?? 0) - (t.match(/[[{]/g)?.length ?? 0);
|
|
69
|
+
if (t === '' && depth <= 0)
|
|
70
|
+
break;
|
|
71
|
+
if (depth <= 0 && !t.startsWith('@') && !/^[\]}]/.test(t))
|
|
72
|
+
break;
|
|
73
|
+
ann.unshift(t);
|
|
74
|
+
}
|
|
75
|
+
return ann.join('\n');
|
|
76
|
+
}
|
|
77
|
+
/** Annotation lines that sit directly above `element` (DDLS or DDLX), joined. Comments are ignored. */
|
|
78
|
+
export function annotationsFor(source, element) {
|
|
79
|
+
const lines = stripCdsComments(source).split(/\r?\n/);
|
|
80
|
+
const re = new RegExp(`^\\s*(key\\s+)?([a-z0-9_.]+\\s+as\\s+)?${escapeRe(element)}\\s*[,;]?\\s*$`, 'i');
|
|
81
|
+
const i = lines.findIndex((l) => re.test(l));
|
|
82
|
+
return i < 0 ? '' : annotationsAt(lines, i);
|
|
83
|
+
}
|
|
84
|
+
/** Splits an annotation block into single annotations (each starts with '@'). */
|
|
85
|
+
function splitAnnotations(block) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const line of block.split('\n')) {
|
|
88
|
+
if (line.startsWith('@') || out.length === 0)
|
|
89
|
+
out.push(line);
|
|
90
|
+
else
|
|
91
|
+
out[out.length - 1] += `\n${line}`;
|
|
92
|
+
}
|
|
93
|
+
return out.filter((a) => a.trim() !== '');
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Field labels in an annotation block. Left out: @UI.facet (a section title)
|
|
97
|
+
* and lineItem/identification entries of `type: #FOR_ACTION` (an action button).
|
|
98
|
+
*/
|
|
99
|
+
function fieldLabels(block) {
|
|
100
|
+
const labels = [];
|
|
101
|
+
for (const a of splitAnnotations(block)) {
|
|
102
|
+
if (/^@UI\.facet\b/i.test(a))
|
|
103
|
+
continue;
|
|
104
|
+
const fieldOnly = a.replace(/\{[^{}]*#FOR_ACTION\b[^{}]*\}/gi, '');
|
|
105
|
+
for (const m of fieldOnly.matchAll(/\blabel\s*:\s*'([^']*)'/gi))
|
|
106
|
+
labels.push(m[1]);
|
|
107
|
+
}
|
|
108
|
+
return labels;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Fields of a view entity body. Handles `key X`, `X`, `x as X`, `path.x as X`,
|
|
112
|
+
* `virtual X : …`, and expressions ending in `as X` (`cast( … ) as X`, a
|
|
113
|
+
* `case … end as X` over several lines). Association exposures (`_Assoc`,
|
|
114
|
+
* `_Assoc : redirected to …`) are skipped. Anything else, and two fields on
|
|
115
|
+
* one line, is reported in `unreadable`; it is never skipped silently.
|
|
116
|
+
*/
|
|
117
|
+
export function ddlsElements(source) {
|
|
118
|
+
const text = stripCdsComments(source);
|
|
119
|
+
const lines = text.split(/\r?\n/);
|
|
120
|
+
const def = text.search(/\bdefine\b/i);
|
|
121
|
+
const open = text.indexOf('{', def < 0 ? 0 : def);
|
|
122
|
+
const close = text.lastIndexOf('}');
|
|
123
|
+
if (open < 0 || close <= open)
|
|
124
|
+
return { elements: [], unreadable: [] };
|
|
125
|
+
const lineOf = (pos) => text.slice(0, pos).match(/\n/g)?.length ?? 0;
|
|
126
|
+
const colOf = (pos) => pos - (text.lastIndexOf('\n', pos - 1) + 1);
|
|
127
|
+
const openLine = lineOf(open);
|
|
128
|
+
const closeLine = lineOf(close);
|
|
129
|
+
const body = [];
|
|
130
|
+
for (let k = openLine; k <= closeLine; k++) {
|
|
131
|
+
const l = lines[k];
|
|
132
|
+
const from = k === openLine ? colOf(open) + 1 : 0;
|
|
133
|
+
const to = k === closeLine ? colOf(close) : l.length;
|
|
134
|
+
body.push(l.slice(from, to));
|
|
135
|
+
}
|
|
136
|
+
// Blank the annotations (they may span lines and contain commas).
|
|
137
|
+
let depth = 0;
|
|
138
|
+
for (let k = 0; k < body.length; k++) {
|
|
139
|
+
const t = body[k].trim();
|
|
140
|
+
if (depth > 0 || t.startsWith('@')) {
|
|
141
|
+
const bare = t.replace(/'[^']*'/g, '');
|
|
142
|
+
depth = Math.max(0, depth + (bare.match(/[[{(]/g)?.length ?? 0) - (bare.match(/[\]})]/g)?.length ?? 0));
|
|
143
|
+
body[k] = '';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const chunks = [];
|
|
147
|
+
let cur = '';
|
|
148
|
+
let first = -1;
|
|
149
|
+
let last = -1;
|
|
150
|
+
let nest = 0;
|
|
151
|
+
let inQuote = false;
|
|
152
|
+
const flush = () => {
|
|
153
|
+
if (cur.trim() !== '')
|
|
154
|
+
chunks.push({ text: cur, first, last });
|
|
155
|
+
cur = '';
|
|
156
|
+
first = -1;
|
|
157
|
+
last = -1;
|
|
158
|
+
};
|
|
159
|
+
for (let k = 0; k < body.length; k++) {
|
|
160
|
+
for (const ch of `${body[k]}\n`) {
|
|
161
|
+
if (!/\s/.test(ch)) {
|
|
162
|
+
if (first < 0)
|
|
163
|
+
first = k;
|
|
164
|
+
last = k;
|
|
165
|
+
}
|
|
166
|
+
if (inQuote) {
|
|
167
|
+
if (ch === "'")
|
|
168
|
+
inQuote = false;
|
|
169
|
+
cur += ch;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (ch === "'")
|
|
173
|
+
inQuote = true;
|
|
174
|
+
else if ('([{'.includes(ch))
|
|
175
|
+
nest++;
|
|
176
|
+
else if (')]}'.includes(ch))
|
|
177
|
+
nest--;
|
|
178
|
+
else if (ch === ',' && nest === 0) {
|
|
179
|
+
flush();
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
cur += ch;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
flush();
|
|
186
|
+
const usage = new Map();
|
|
187
|
+
for (const c of chunks)
|
|
188
|
+
for (let k = c.first; k <= c.last; k++)
|
|
189
|
+
usage.set(k, (usage.get(k) ?? 0) + 1);
|
|
190
|
+
const bad = new Set();
|
|
191
|
+
const elements = [];
|
|
192
|
+
for (const c of chunks) {
|
|
193
|
+
const line = openLine + c.first;
|
|
194
|
+
const shared = [...Array(c.last - c.first + 1).keys()].map((d) => c.first + d).find((k) => (usage.get(k) ?? 0) > 1);
|
|
195
|
+
if (shared !== undefined) {
|
|
196
|
+
bad.add(openLine + shared + 1);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const s = c.text.replace(/\s+/g, ' ').trim();
|
|
200
|
+
if (/^_\w+(\s*:.*)?$/.test(s))
|
|
201
|
+
continue;
|
|
202
|
+
const virt = /^(?:key\s+)?virtual\s+([A-Za-z_]\w*)\s*:/i.exec(s);
|
|
203
|
+
if (virt) {
|
|
204
|
+
elements.push({ name: virt[1], line });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const m = /^(?:key\s+)?(?:(.+?)\s+as\s+)?([A-Za-z_]\w*)$/i.exec(s);
|
|
208
|
+
if (!m) {
|
|
209
|
+
bad.add(line + 1);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
elements.push(m[1] && /^[\w.]+$/.test(m[1]) ? { name: m[2], source: m[1], line } : { name: m[2], line });
|
|
213
|
+
}
|
|
214
|
+
return { elements, unreadable: [...bad].sort((x, y) => x - y) };
|
|
215
|
+
}
|
|
216
|
+
/** ABAP source without comment lines (`*` in column 1) and end-of-line `"` comments. */
|
|
217
|
+
function stripAbapComments(src) {
|
|
218
|
+
return src.split(/\r?\n/).filter((l) => !l.startsWith('*')).map((l) => l.replace(/".*$/, '')).join('\n');
|
|
219
|
+
}
|
|
220
|
+
/** F1 — instance authorization with a real AUTHORITY-CHECK. Expects comment-free BDEF. */
|
|
221
|
+
function checkAuth(src, opts) {
|
|
222
|
+
const problems = [];
|
|
223
|
+
if (!/authorization\s+master\s*\(\s*instance\s*\)/i.test(src.rootBdef)) {
|
|
224
|
+
problems.push(/authorization\s+master\s*\(\s*global\s*\)/i.test(src.rootBdef)
|
|
225
|
+
? 'Authorization is global; it must be instance'
|
|
226
|
+
: `${opts.root} has no instance authorization; add authorization master ( instance )`);
|
|
227
|
+
}
|
|
228
|
+
const code = stripAbapComments(src.ccimp);
|
|
229
|
+
const start = /\bMETHOD\s+get_instance_authorizations\s*\./i.exec(code);
|
|
230
|
+
if (!start) {
|
|
231
|
+
problems.push('get_instance_authorizations is not implemented');
|
|
232
|
+
return item('F1', problems);
|
|
233
|
+
}
|
|
234
|
+
const rest = code.slice(start.index + start[0].length);
|
|
235
|
+
const end = /\bENDMETHOD\b/i.exec(rest);
|
|
236
|
+
const body = end ? rest.slice(0, end.index) : rest;
|
|
237
|
+
if (!/\bAUTHORITY-CHECK\b/i.test(body)) {
|
|
238
|
+
problems.push('get_instance_authorizations has no AUTHORITY-CHECK');
|
|
239
|
+
}
|
|
240
|
+
else if (opts.authObject && !new RegExp(`\\bOBJECT\\s+'${escapeRe(opts.authObject)}'`, 'i').test(body)) {
|
|
241
|
+
problems.push(`get_instance_authorizations does not check ${opts.authObject}`);
|
|
242
|
+
}
|
|
243
|
+
return item('F1', problems);
|
|
244
|
+
}
|
|
245
|
+
/** F2 — an access control for the root view and for the projection. */
|
|
246
|
+
function checkDcl(src, opts) {
|
|
247
|
+
const have = new Set(src.dclsNames.map((n) => n.toUpperCase()));
|
|
248
|
+
const problems = [opts.root, opts.projection]
|
|
249
|
+
.filter((n) => !have.has(n.toUpperCase()))
|
|
250
|
+
.map((n) => `No access control for ${n}`);
|
|
251
|
+
return item('F2', problems);
|
|
252
|
+
}
|
|
253
|
+
const ABP_ADMIN_TYPE = /^abp_(?:creation_(?:user|tstmpl)|lastchange_(?:user|tstmpl)|locinst_\w+)$/i;
|
|
254
|
+
/** Columns of a `define table` source typed with an abp_* admin type (lower-case names). */
|
|
255
|
+
function abpAdminColumns(table) {
|
|
256
|
+
if (!table)
|
|
257
|
+
return [];
|
|
258
|
+
const cols = [];
|
|
259
|
+
for (const m of table.matchAll(/^\s*(?:key\s+)?([A-Za-z_]\w*)\s*:\s*([A-Za-z_]\w*)/gim)) {
|
|
260
|
+
if (ABP_ADMIN_TYPE.test(m[2]))
|
|
261
|
+
cols.push(m[1].toLowerCase());
|
|
262
|
+
}
|
|
263
|
+
return cols;
|
|
264
|
+
}
|
|
265
|
+
/** F3 — admin fields read-only; a UUID key uses managed numbering. Expects comment-free sources. */
|
|
266
|
+
function checkAdminReadOnly(src, opts) {
|
|
267
|
+
const root = ddlsElements(src.rootDdls);
|
|
268
|
+
const problems = root.unreadable.map((l) => unreadable(l, opts.root));
|
|
269
|
+
const readonly = new Set();
|
|
270
|
+
for (const m of src.rootBdef.matchAll(/field\s*\(\s*readonly\s*\)\s*([^;]+);/gi)) {
|
|
271
|
+
for (const f of m[1].split(','))
|
|
272
|
+
readonly.add(f.trim().toLowerCase());
|
|
273
|
+
}
|
|
274
|
+
const names = new Set(root.elements.map((e) => e.name.toLowerCase()));
|
|
275
|
+
// Final review M7 — with the table source, the admin fields are also the
|
|
276
|
+
// columns typed abp_* (creation/lastchange user + timestamp, locinst_*),
|
|
277
|
+
// under whatever alias the root view gives them. The fixed names stay the fallback.
|
|
278
|
+
const admin = ADMIN_FIELDS.filter((f) => names.has(f.toLowerCase()));
|
|
279
|
+
for (const col of abpAdminColumns(src.table)) {
|
|
280
|
+
const el = root.elements.find((e) => e.source !== undefined && e.source.split('.').pop().toLowerCase() === col);
|
|
281
|
+
if (el && !admin.some((f) => f.toLowerCase() === el.name.toLowerCase()))
|
|
282
|
+
admin.push(el.name);
|
|
283
|
+
}
|
|
284
|
+
for (const f of admin) {
|
|
285
|
+
if (!readonly.has(f.toLowerCase()))
|
|
286
|
+
problems.push(`${f} can be edited; make it read-only`);
|
|
287
|
+
}
|
|
288
|
+
const uuidKey = src.table ? /\bkey\s+(\w+)\s*:\s*sysuuid_x16\b/i.exec(src.table) : null;
|
|
289
|
+
if (uuidKey && !/numbering\s*:\s*managed/i.test(src.rootBdef)) {
|
|
290
|
+
problems.push(`The key ${uuidKey[1]} is a UUID; add numbering : managed`);
|
|
291
|
+
}
|
|
292
|
+
return item('F3', problems);
|
|
293
|
+
}
|
|
294
|
+
/** F4 — every visible field has a real label; the app title is a business name. Expects comment-free sources. */
|
|
295
|
+
function checkLabels(src, opts) {
|
|
296
|
+
if (src.ddlx === undefined)
|
|
297
|
+
return item('F4', [`No metadata extension for ${opts.projection}`]);
|
|
298
|
+
const ddlx = src.ddlx;
|
|
299
|
+
const proj = ddlsElements(src.projectionDdls);
|
|
300
|
+
const root = ddlsElements(src.rootDdls).elements;
|
|
301
|
+
const projLines = src.projectionDdls.split(/\r?\n/);
|
|
302
|
+
const rootLines = src.rootDdls.split(/\r?\n/);
|
|
303
|
+
const problems = proj.unreadable.map((l) => unreadable(l, opts.projection));
|
|
304
|
+
let emptyAttributed = 0;
|
|
305
|
+
for (const el of proj.elements) {
|
|
306
|
+
const xBlock = annotationsFor(ddlx, el.name);
|
|
307
|
+
const pBlock = annotationsAt(projLines, el.line);
|
|
308
|
+
const rootEl = root.find((r) => eqi(r.name, el.source ?? el.name));
|
|
309
|
+
const rBlock = rootEl ? annotationsAt(rootLines, rootEl.line) : '';
|
|
310
|
+
const xLabels = fieldLabels(xBlock);
|
|
311
|
+
const empties = xLabels.filter((l) => l.trim() === '').length;
|
|
312
|
+
emptyAttributed += empties;
|
|
313
|
+
if (empties > 0)
|
|
314
|
+
problems.push(`${el.name} has an empty label`);
|
|
315
|
+
if (xLabels.some((l) => l.length > 8 && !/\s/.test(l) && eqi(l, el.name))) {
|
|
316
|
+
problems.push(`${el.name} label is just the field name`);
|
|
317
|
+
}
|
|
318
|
+
const hidden = /@UI\.hidden\s*:\s*true\b/i.test(`${xBlock}\n${pBlock}`);
|
|
319
|
+
const endUser = (b) => [...b.matchAll(/@EndUserText\.label\s*:\s*'([^']*)'/gi)].map((m) => m[1]);
|
|
320
|
+
const all = [...xLabels, ...endUser(pBlock), ...endUser(rBlock)];
|
|
321
|
+
if (!hidden && empties === 0 && !all.some((l) => l.trim() !== ''))
|
|
322
|
+
problems.push(`${el.name} has no label`);
|
|
323
|
+
}
|
|
324
|
+
const emptyTotal = [...ddlx.matchAll(/\blabel\s*:\s*''/gi)].length;
|
|
325
|
+
if (emptyTotal > emptyAttributed)
|
|
326
|
+
problems.push('A section of the metadata extension has an empty label');
|
|
327
|
+
const header = `${ddlx}\n${src.projectionDdls}`;
|
|
328
|
+
const typeName = /\btypeName\s*:\s*'([^']*)'/i.exec(header)?.[1]?.trim();
|
|
329
|
+
const plural = /\btypeNamePlural\s*:\s*'([^']*)'/i.exec(header)?.[1]?.trim();
|
|
330
|
+
if (!typeName)
|
|
331
|
+
problems.push('The app has no title (headerInfo typeName)');
|
|
332
|
+
else if (eqi(typeName, opts.entity))
|
|
333
|
+
problems.push(`The app title is ${typeName}; use the business name`);
|
|
334
|
+
if (!plural)
|
|
335
|
+
problems.push('The app has no plural title (headerInfo typeNamePlural)');
|
|
336
|
+
else if (eqi(plural, opts.entity) || eqi(plural, `${opts.entity}s`)) {
|
|
337
|
+
problems.push(`The plural app title is ${plural}; use the business name`);
|
|
338
|
+
}
|
|
339
|
+
return item('F4', problems);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* F5 — coded fields show a value help and their text, not the raw code. The
|
|
343
|
+
* text element must be a field of the projection (a path starting with `_` is
|
|
344
|
+
* accepted), or the field uses @ObjectModel.text.association. Expects comment-free sources.
|
|
345
|
+
*/
|
|
346
|
+
function checkCodedFields(src, opts) {
|
|
347
|
+
const problems = [];
|
|
348
|
+
const elements = ddlsElements(src.projectionDdls).elements;
|
|
349
|
+
const projLines = src.projectionDdls.split(/\r?\n/);
|
|
350
|
+
for (const f of opts.codedFields ?? []) {
|
|
351
|
+
const el = elements.find((e) => eqi(e.name, f));
|
|
352
|
+
if (!el) {
|
|
353
|
+
problems.push(`${f} is not in ${opts.projection}`);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const ann = `${annotationsAt(projLines, el.line)}\n${src.ddlx ? annotationsFor(src.ddlx, el.name) : ''}`;
|
|
357
|
+
if (!/@Consumption\.valueHelpDefinition\b/i.test(ann))
|
|
358
|
+
problems.push(`${f} has no value help`);
|
|
359
|
+
if (!/@ObjectModel\.text\.association\b/i.test(ann)) {
|
|
360
|
+
const te = /@ObjectModel\.text\.element\s*:\s*\[\s*'([^']+)'/i.exec(ann)?.[1]?.trim();
|
|
361
|
+
if (!te)
|
|
362
|
+
problems.push(`${f} has no text`);
|
|
363
|
+
else if (!te.startsWith('_') && !elements.some((e) => eqi(e.name, te))) {
|
|
364
|
+
problems.push(`${f} shows text ${te}, which is not in ${opts.projection}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (!/#TEXT_ONLY\b/i.test(ann))
|
|
368
|
+
problems.push(`${f} does not show its text (#TEXT_ONLY)`);
|
|
369
|
+
}
|
|
370
|
+
return item('F5', problems);
|
|
371
|
+
}
|
|
372
|
+
/** F1–F5, always five items, in order. Comments are stripped first. */
|
|
373
|
+
export function checkFinish(src, opts) {
|
|
374
|
+
const clean = {
|
|
375
|
+
...src,
|
|
376
|
+
rootDdls: stripCdsComments(src.rootDdls),
|
|
377
|
+
projectionDdls: stripCdsComments(src.projectionDdls),
|
|
378
|
+
rootBdef: stripCdsComments(src.rootBdef),
|
|
379
|
+
...(src.ddlx !== undefined ? { ddlx: stripCdsComments(src.ddlx) } : {}),
|
|
380
|
+
...(src.table !== undefined ? { table: stripCdsComments(src.table) } : {}),
|
|
381
|
+
};
|
|
382
|
+
return [
|
|
383
|
+
checkAuth(clean, opts),
|
|
384
|
+
checkDcl(clean, opts),
|
|
385
|
+
checkAdminReadOnly(clean, opts),
|
|
386
|
+
checkLabels(clean, opts),
|
|
387
|
+
checkCodedFields(clean, opts),
|
|
388
|
+
];
|
|
389
|
+
}
|